From afdade901166ddab5c8c4a5a10fbd1feed505296 Mon Sep 17 00:00:00 2001 From: Matheus Henrique de Souza Date: Mon, 10 Aug 2026 14:35:43 -0300 Subject: [PATCH 1/6] fix(ci): use pull_request closed trigger for auto RC generation on develop merge GITHUB_TOKEN auto-merges suppress push events, preventing the Release Pipeline from firing. Switch develop trigger from push to pull_request[closed] with merged guard, fix checkout ref to tag the actual merge commit, and configure explicit git credentials for tag push. --- .github/workflows/release.yml | 31 +++++++++++++++++++++++++------ CONTEXT.md | 3 ++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d375702..c07cc8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,14 @@ name: Release Pipeline on: push: branches: - - develop - main tags: - 'v*.*.*' + pull_request: + types: [closed] + branches: + - develop + - main workflow_dispatch: inputs: version: @@ -14,10 +18,15 @@ on: required: true default: 'v1.0.0-rc.26' +concurrency: + group: release-${{ github.event.pull_request.base.ref || github.ref_name }} + cancel-in-progress: false + jobs: release: name: Build, Scan & Publish Release Artifacts runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true permissions: contents: write packages: write @@ -28,6 +37,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} - name: Determine & Auto-Create Release Tag id: release_tag @@ -36,15 +46,22 @@ jobs: INPUT_VERSION: ${{ inputs.version }} REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} + BASE_REF: ${{ github.event.pull_request.base.ref }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPOSITORY: ${{ github.repository }} run: | git fetch --tags origin + TARGET_BRANCH="$REF_NAME" + if [ "$EVENT_NAME" = "pull_request" ]; then + TARGET_BRANCH="$BASE_REF" + fi + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then TAG="$INPUT_VERSION" elif [ "$REF_TYPE" = "tag" ]; then TAG="$REF_NAME" - elif [ "$REF_NAME" = "develop" ]; then + elif [ "$TARGET_BRANCH" = "develop" ]; then LATEST_RC=$(git tag -l "v*-rc.*" | sort -V | tail -n 1) if [ -z "$LATEST_RC" ]; then TAG="v1.0.0-rc.1" @@ -57,11 +74,12 @@ jobs: echo "Auto-generated Release Candidate Tag for develop: $TAG" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPOSITORY}.git" if ! git rev-parse "$TAG" >/dev/null 2>&1; then git tag -a "$TAG" -m "Automated release candidate $TAG" - git push origin "$TAG" || true + git push origin "$TAG" fi - elif [ "$REF_NAME" = "main" ]; then + elif [ "$TARGET_BRANCH" = "main" ]; then LATEST_RC=$(git tag -l "v*-rc.*" | sort -V | tail -n 1) if [ -n "$LATEST_RC" ]; then TAG=$(echo "$LATEST_RC" | sed -E 's/-rc\.[0-9]+$//') @@ -76,12 +94,13 @@ jobs: echo "Auto-generated Official Release Tag for main: $TAG" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPOSITORY}.git" if ! git rev-parse "$TAG" >/dev/null 2>&1; then git tag -a "$TAG" -m "Automated official release $TAG" - git push origin "$TAG" || true + git push origin "$TAG" fi else - echo "Error: Unsupported ref $REF_NAME" + echo "Error: Unsupported ref $TARGET_BRANCH" exit 1 fi diff --git a/CONTEXT.md b/CONTEXT.md index a7d3485..c671c7a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -92,7 +92,7 @@ Guidelines below are continuously updated from internal code reviews, CodeRabbit 29. **URL Path Sanitization for File Serving**: Code serving files from `fs.FS` based on URL paths MUST use `filepath.Clean` (not `path.Clean`) for sanitization, followed by `filepath.ToSlash` to maintain forward-slash compatibility with `fs.FS`. Semgrep flags `path.Clean` on user input as `filepath-clean-misuse`. 30. **External Coverage Tool Consistency**: When using external coverage tools (Codecov) as merge gates with multiple CI jobs uploading coverage, configure `after_n_builds: N` (N = number of upload jobs) and `wait_for_ci: true` to prevent premature evaluation. The tool may post a SUCCESS CheckRun after partial data, then correct to FAILURE — but auto-merge already triggered. 31. **Required Status Checks Completeness**: Every CI quality gate job MUST be added to the branch protection required status checks list. A job that runs and fails but is not required will NOT block the merge. Verify with `gh api repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks`. -32. **GITHUB_TOKEN Workflow Trigger Limitation**: `GITHUB_TOKEN` pushes (including auto-merge) do NOT trigger subsequent workflow runs on the target branch. CI workflows MUST include `workflow_dispatch` for manual baseline refresh. Coverage tools using `target: auto` will have stale baselines without this. +32. **GITHUB_TOKEN Workflow Trigger Limitation**: `GITHUB_TOKEN` pushes (including auto-merge) do NOT trigger subsequent workflow runs on the target branch. Workflows that must fire on merge (e.g., Release Pipeline) MUST use `pull_request: types: [closed]` with an `if: github.event.pull_request.merged == true` guard instead of `push:` triggers. CI workflows MUST include `workflow_dispatch` for manual baseline refresh. Coverage tools using `target: auto` will have stale baselines without this. See also #100. 33. **Pagination/Normalization Tests Must Assert Normalized Values**: Tests exercising pagination defaults (zero/negative page, out-of-range perPage) MUST assert the actual normalized values passed to the repository mock — not just `err == nil`. Capture limit/offset/status via mock fields and verify against expected defaults. 34. **Concurrent Async API Batching in ViewModels**: When fetching multiple independent API endpoints for a screen/dashboard, ViewModels MUST execute requests concurrently using `coroutineScope` + `async { ... }` instead of sequential `await`s to minimize total network latency. Always cancel any in-flight fetch job before launching a new fetch to prevent stale async responses from overwriting updated state. 35. **Lifecycle Disposal & Cleanup for ViewModels in Compose**: When a Composable instantiates or remembers a ViewModel with background coroutines/jobs (such as auto-refresh loops or SSE event listeners), use `DisposableEffect(viewModel)` with `onDispose { viewModel.clear() }` to guarantee that background jobs are cancelled when the route unmounts or dependencies change. @@ -160,3 +160,4 @@ Guidelines below are continuously updated from internal code reviews, CodeRabbit 97. **Dedicated `onError` Token for Dark Themes**: Dark color schemes MUST define a dedicated dark `onError` color with high contrast over the `error` surface, not reuse `onBackground`/`onSurface` (which are light text colors). Light foreground text (`#f8f8f2`) over a bright error surface (`#ff5555`) fails WCAG contrast. Use a near-black token (e.g., `#1a0a0a`). 98. **Inline Health Fallback Over Separate Fallback Jobs**: When a ViewModel fetches multiple concurrent API results and some are auth-gated while health is public, the error handler MUST extract health data inline from the already-awaited health result instead of launching a separate fallback job. Separate fallback jobs create race conditions with auto-refresh and SSE-triggered reloads, leading to out-of-order state updates. 99. **Generation Token for Stale Response Rejection**: ViewModels executing async fetch operations that can be re-triggered (manual refresh, SSE events, auto-refresh) MUST use a monotonic generation counter. Each trigger increments the counter; the async handler captures the generation at start and skips state updates if the generation has advanced (another fetch was triggered while this one was in-flight). +100. **Release Pipeline Must Use `pull_request: closed` Trigger for Auto-Merge Branches**: Workflows that must run after auto-merge (e.g., Release Pipeline creating RC tags) MUST trigger on `pull_request: types: [closed]` with `if: merged == true`, NOT on `push:` to the target branch. `GITHUB_TOKEN` auto-merges suppress `push` events (#32). The checkout step MUST use `ref: ${{ github.event.pull_request.merge_commit_sha }}` to tag the actual merge commit (not the test merge ref). Git push credentials MUST be configured explicitly via `git remote set-url` with the token since `persist-credentials: false` is required (#80). Add `concurrency` groups keyed on the target branch to prevent duplicate runs. From 107a81fa1a00263f9fb2f5c1f93397be0da04d42 Mon Sep 17 00:00:00 2001 From: Matheus Henrique de Souza Date: Mon, 10 Aug 2026 15:36:23 -0300 Subject: [PATCH 2/6] fix(frontend): disable webpack content-hash on WASM assets Kotlin/WASM bootstrap loads .wasm files by their original names (skiko.wasm, inframap-frontend-wasm-js.wasm), but webpack's production build hashes them. The SPA handler serves index.html (text/html) for missing files, causing WebAssembly.compile to reject the wrong MIME type. --- frontend/webpack.config.d/wasm-assets.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 frontend/webpack.config.d/wasm-assets.js diff --git a/frontend/webpack.config.d/wasm-assets.js b/frontend/webpack.config.d/wasm-assets.js new file mode 100644 index 0000000..a19f085 --- /dev/null +++ b/frontend/webpack.config.d/wasm-assets.js @@ -0,0 +1,7 @@ +config.module.rules.push({ + test: /\.wasm$/, + type: "asset/resource", + generator: { + filename: "[name][ext]" + } +}); From 3d292d95004b34ff874e943e65c9866a6645749b Mon Sep 17 00:00:00 2001 From: Matheus Henrique de Souza Date: Mon, 10 Aug 2026 15:38:44 -0300 Subject: [PATCH 3/6] fix(spa): return 404 for missing static assets instead of SPA fallback Static asset requests (.wasm, .js, .css, etc.) that don't exist in the embedded FS now return 404 instead of falling back to index.html. This prevents the browser from receiving HTML when it expects a binary asset, making missing file errors immediately visible in devtools. --- CONTEXT.md | 1 + backend/internal/platform/spa/handler.go | 13 +++++++++++++ backend/internal/platform/spa/handler_test.go | 15 +++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index c671c7a..3c4037b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -161,3 +161,4 @@ Guidelines below are continuously updated from internal code reviews, CodeRabbit 98. **Inline Health Fallback Over Separate Fallback Jobs**: When a ViewModel fetches multiple concurrent API results and some are auth-gated while health is public, the error handler MUST extract health data inline from the already-awaited health result instead of launching a separate fallback job. Separate fallback jobs create race conditions with auto-refresh and SSE-triggered reloads, leading to out-of-order state updates. 99. **Generation Token for Stale Response Rejection**: ViewModels executing async fetch operations that can be re-triggered (manual refresh, SSE events, auto-refresh) MUST use a monotonic generation counter. Each trigger increments the counter; the async handler captures the generation at start and skips state updates if the generation has advanced (another fetch was triggered while this one was in-flight). 100. **Release Pipeline Must Use `pull_request: closed` Trigger for Auto-Merge Branches**: Workflows that must run after auto-merge (e.g., Release Pipeline creating RC tags) MUST trigger on `pull_request: types: [closed]` with `if: merged == true`, NOT on `push:` to the target branch. `GITHUB_TOKEN` auto-merges suppress `push` events (#32). The checkout step MUST use `ref: ${{ github.event.pull_request.merge_commit_sha }}` to tag the actual merge commit (not the test merge ref). Git push credentials MUST be configured explicitly via `git remote set-url` with the token since `persist-credentials: false` is required (#80). Add `concurrency` groups keyed on the target branch to prevent duplicate runs. +101. **Kotlin/WASM Production Builds Must Disable Content-Hash on WASM Assets**: Webpack production builds content-hash asset filenames by default (e.g., `a92a356b.wasm`), but the Kotlin/WASM bootstrap and Skiko runtime load `.wasm` files by their original names (`inframap-frontend-wasm-js.wasm`, `skiko.wasm`) via hardcoded string paths. A `webpack.config.d/` rule MUST override the WASM asset filename to `[name][ext]`. Without this, the SPA fallback serves `index.html` (text/html) for the missing unhashed filenames, causing `WebAssembly.compile` to reject the incorrect MIME type. diff --git a/backend/internal/platform/spa/handler.go b/backend/internal/platform/spa/handler.go index 4115288..c3f2283 100644 --- a/backend/internal/platform/spa/handler.go +++ b/backend/internal/platform/spa/handler.go @@ -29,6 +29,10 @@ func NewSPAHandler(fsys fs.FS) http.Handler { _, err := fs.Stat(fsys, urlPath) if err != nil { + if isStaticAsset(urlPath) { + http.NotFound(w, r) + return + } urlPath = "index.html" } @@ -57,6 +61,15 @@ func setCacheHeaders(w http.ResponseWriter, filePath string) { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } +func isStaticAsset(filePath string) bool { + switch filepath.Ext(filePath) { + case ".wasm", ".js", ".css", ".json", ".map", + ".svg", ".png", ".jpg", ".ico", ".woff", ".woff2", ".ttf": + return true + } + return false +} + func setContentType(w http.ResponseWriter, filePath string) { ext := filepath.Ext(filePath) switch ext { diff --git a/backend/internal/platform/spa/handler_test.go b/backend/internal/platform/spa/handler_test.go index 21b2941..2dacf26 100644 --- a/backend/internal/platform/spa/handler_test.go +++ b/backend/internal/platform/spa/handler_test.go @@ -193,6 +193,21 @@ func TestSPAHandler_FallbackAlsoGetsCacheNoCache(t *testing.T) { } } +func TestSPAHandler_MissingStaticAssetReturns404(t *testing.T) { + handler := spa.NewSPAHandler(testFS()) + + paths := []string{"/missing.wasm", "/missing.js", "/missing.css", "/missing.json", "/missing.map"} + for _, p := range paths { + req := httptest.NewRequest(http.MethodGet, p, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("path %s: expected 404 for missing static asset, got %d", p, rec.Code) + } + } +} + func TestSPAHandler_MissingIndexHTMLReturns404(t *testing.T) { emptyFS := fstest.MapFS{} handler := spa.NewSPAHandler(emptyFS) From 938b2ee26b8ba5021559af81ec028653abc3acd0 Mon Sep 17 00:00:00 2001 From: Matheus Henrique de Souza Date: Mon, 10 Aug 2026 17:39:34 -0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(ci):=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20scoped=20credentials,=20global=20concurrency,=20bra?= =?UTF-8?q?nch=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use temporary git -c http.extraHeader for tag push instead of persisting token via git remote set-url - Use single global concurrency group (release-pipeline) to serialize all releases across develop and main - Restrict workflow_dispatch to develop/main branches only --- .github/workflows/release.yml | 15 +++++++++------ CONTEXT.md | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c07cc8c..38bb5da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,14 +19,19 @@ on: default: 'v1.0.0-rc.26' concurrency: - group: release-${{ github.event.pull_request.base.ref || github.ref_name }} + group: release-pipeline cancel-in-progress: false jobs: release: name: Build, Scan & Publish Release Artifacts runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.merged == true + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true) || + (github.event_name == 'push') || + (github.event_name == 'workflow_dispatch' && + (github.ref_name == 'develop' || github.ref_name == 'main')) permissions: contents: write packages: write @@ -74,10 +79,9 @@ jobs: echo "Auto-generated Release Candidate Tag for develop: $TAG" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPOSITORY}.git" if ! git rev-parse "$TAG" >/dev/null 2>&1; then git tag -a "$TAG" -m "Automated release candidate $TAG" - git push origin "$TAG" + git -c http.extraHeader="Authorization: Basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64)" push origin "$TAG" fi elif [ "$TARGET_BRANCH" = "main" ]; then LATEST_RC=$(git tag -l "v*-rc.*" | sort -V | tail -n 1) @@ -94,10 +98,9 @@ jobs: echo "Auto-generated Official Release Tag for main: $TAG" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GH_REPOSITORY}.git" if ! git rev-parse "$TAG" >/dev/null 2>&1; then git tag -a "$TAG" -m "Automated official release $TAG" - git push origin "$TAG" + git -c http.extraHeader="Authorization: Basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64)" push origin "$TAG" fi else echo "Error: Unsupported ref $TARGET_BRANCH" diff --git a/CONTEXT.md b/CONTEXT.md index 3c4037b..10539dd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -160,5 +160,5 @@ Guidelines below are continuously updated from internal code reviews, CodeRabbit 97. **Dedicated `onError` Token for Dark Themes**: Dark color schemes MUST define a dedicated dark `onError` color with high contrast over the `error` surface, not reuse `onBackground`/`onSurface` (which are light text colors). Light foreground text (`#f8f8f2`) over a bright error surface (`#ff5555`) fails WCAG contrast. Use a near-black token (e.g., `#1a0a0a`). 98. **Inline Health Fallback Over Separate Fallback Jobs**: When a ViewModel fetches multiple concurrent API results and some are auth-gated while health is public, the error handler MUST extract health data inline from the already-awaited health result instead of launching a separate fallback job. Separate fallback jobs create race conditions with auto-refresh and SSE-triggered reloads, leading to out-of-order state updates. 99. **Generation Token for Stale Response Rejection**: ViewModels executing async fetch operations that can be re-triggered (manual refresh, SSE events, auto-refresh) MUST use a monotonic generation counter. Each trigger increments the counter; the async handler captures the generation at start and skips state updates if the generation has advanced (another fetch was triggered while this one was in-flight). -100. **Release Pipeline Must Use `pull_request: closed` Trigger for Auto-Merge Branches**: Workflows that must run after auto-merge (e.g., Release Pipeline creating RC tags) MUST trigger on `pull_request: types: [closed]` with `if: merged == true`, NOT on `push:` to the target branch. `GITHUB_TOKEN` auto-merges suppress `push` events (#32). The checkout step MUST use `ref: ${{ github.event.pull_request.merge_commit_sha }}` to tag the actual merge commit (not the test merge ref). Git push credentials MUST be configured explicitly via `git remote set-url` with the token since `persist-credentials: false` is required (#80). Add `concurrency` groups keyed on the target branch to prevent duplicate runs. +100. **Release Pipeline Must Use `pull_request: closed` Trigger for Auto-Merge Branches**: Workflows that must run after auto-merge (e.g., Release Pipeline creating RC tags) MUST trigger on `pull_request: types: [closed]` with `if: merged == true`, NOT on `push:` to the target branch. `GITHUB_TOKEN` auto-merges suppress `push` events (#32). The checkout step MUST use `ref: ${{ github.event.pull_request.merge_commit_sha }}` to tag the actual merge commit (not the test merge ref). Git push credentials MUST use temporary scoped auth (`git -c http.extraHeader="Authorization: ..."`) instead of persisting tokens via `git remote set-url` (#80). Use a single global concurrency group (`release-pipeline`) to serialize all releases across branches. Restrict `workflow_dispatch` to protected branches (`develop`/`main`) via explicit `if` conditions. 101. **Kotlin/WASM Production Builds Must Disable Content-Hash on WASM Assets**: Webpack production builds content-hash asset filenames by default (e.g., `a92a356b.wasm`), but the Kotlin/WASM bootstrap and Skiko runtime load `.wasm` files by their original names (`inframap-frontend-wasm-js.wasm`, `skiko.wasm`) via hardcoded string paths. A `webpack.config.d/` rule MUST override the WASM asset filename to `[name][ext]`. Without this, the SPA fallback serves `index.html` (text/html) for the missing unhashed filenames, causing `WebAssembly.compile` to reject the incorrect MIME type. From 4f9e5b62942cb7dafc1714c9ce127bde1934ad68 Mon Sep 17 00:00:00 2001 From: Matheus Henrique de Souza Date: Mon, 10 Aug 2026 19:47:40 -0300 Subject: [PATCH 5/6] =?UTF-8?q?fix(spa,ci):=20address=20CodeRabbit=20revie?= =?UTF-8?q?w=20=E2=80=94=20error=20classification,=20WASM=20cache,=20push?= =?UTF-8?q?=20trigger=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Classify fs.Stat errors: 404 for ErrNotExist, 500 for I/O/permission - Use revalidation cache (no-cache) for stable-named .wasm files instead of immutable (prevents stale WASM after release updates) - Remove push: branches: [main] from release.yml — pull_request: closed already covers all branch merges; keep only push: tags - Add tag-type guard to push event condition --- .github/workflows/release.yml | 5 ++-- CONTEXT.md | 4 ++- backend/internal/platform/spa/handler.go | 11 ++++++- backend/internal/platform/spa/handler_test.go | 29 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38bb5da..7deb747 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,6 @@ name: Release Pipeline on: push: - branches: - - main tags: - 'v*.*.*' pull_request: @@ -29,7 +27,8 @@ jobs: if: >- (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || - (github.event_name == 'push') || + (github.event_name == 'push' && + github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && (github.ref_name == 'develop' || github.ref_name == 'main')) permissions: diff --git a/CONTEXT.md b/CONTEXT.md index 10539dd..6b5edbc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -160,5 +160,7 @@ Guidelines below are continuously updated from internal code reviews, CodeRabbit 97. **Dedicated `onError` Token for Dark Themes**: Dark color schemes MUST define a dedicated dark `onError` color with high contrast over the `error` surface, not reuse `onBackground`/`onSurface` (which are light text colors). Light foreground text (`#f8f8f2`) over a bright error surface (`#ff5555`) fails WCAG contrast. Use a near-black token (e.g., `#1a0a0a`). 98. **Inline Health Fallback Over Separate Fallback Jobs**: When a ViewModel fetches multiple concurrent API results and some are auth-gated while health is public, the error handler MUST extract health data inline from the already-awaited health result instead of launching a separate fallback job. Separate fallback jobs create race conditions with auto-refresh and SSE-triggered reloads, leading to out-of-order state updates. 99. **Generation Token for Stale Response Rejection**: ViewModels executing async fetch operations that can be re-triggered (manual refresh, SSE events, auto-refresh) MUST use a monotonic generation counter. Each trigger increments the counter; the async handler captures the generation at start and skips state updates if the generation has advanced (another fetch was triggered while this one was in-flight). -100. **Release Pipeline Must Use `pull_request: closed` Trigger for Auto-Merge Branches**: Workflows that must run after auto-merge (e.g., Release Pipeline creating RC tags) MUST trigger on `pull_request: types: [closed]` with `if: merged == true`, NOT on `push:` to the target branch. `GITHUB_TOKEN` auto-merges suppress `push` events (#32). The checkout step MUST use `ref: ${{ github.event.pull_request.merge_commit_sha }}` to tag the actual merge commit (not the test merge ref). Git push credentials MUST use temporary scoped auth (`git -c http.extraHeader="Authorization: ..."`) instead of persisting tokens via `git remote set-url` (#80). Use a single global concurrency group (`release-pipeline`) to serialize all releases across branches. Restrict `workflow_dispatch` to protected branches (`develop`/`main`) via explicit `if` conditions. +100. **Release Pipeline Must Use `pull_request: closed` Trigger for Auto-Merge Branches**: Workflows that must run after auto-merge (e.g., Release Pipeline creating RC tags) MUST trigger on `pull_request: types: [closed]` with `if: merged == true`, NOT on `push: branches:` to the target branch. `GITHUB_TOKEN` auto-merges suppress `push` events (#32). The `push:` trigger MUST only retain `tags:` patterns for manual tag pushes — branch entries MUST be removed since `pull_request: closed` already covers all branch merges. The checkout step MUST use `ref: ${{ github.event.pull_request.merge_commit_sha }}` to tag the actual merge commit (not the test merge ref). Git push credentials MUST use temporary scoped auth (`git -c http.extraHeader="Authorization: ..."`) instead of persisting tokens via `git remote set-url` (#80). Use a single global concurrency group (`release-pipeline`) to serialize all releases across branches. Restrict `workflow_dispatch` to protected branches (`develop`/`main`) via explicit `if` conditions. 101. **Kotlin/WASM Production Builds Must Disable Content-Hash on WASM Assets**: Webpack production builds content-hash asset filenames by default (e.g., `a92a356b.wasm`), but the Kotlin/WASM bootstrap and Skiko runtime load `.wasm` files by their original names (`inframap-frontend-wasm-js.wasm`, `skiko.wasm`) via hardcoded string paths. A `webpack.config.d/` rule MUST override the WASM asset filename to `[name][ext]`. Without this, the SPA fallback serves `index.html` (text/html) for the missing unhashed filenames, causing `WebAssembly.compile` to reject the incorrect MIME type. +102. **Stable-Named Assets Must Not Use Immutable Cache**: When unhashed WASM assets use stable filenames across releases (e.g., `skiko.wasm`), the SPA handler MUST NOT serve them with `Cache-Control: immutable`. Browsers would cache the old binary indefinitely and never revalidate, causing runtime incompatibility when the JS bundle updates but the WASM stays stale. Use `Cache-Control: public, no-cache` for `.wasm` files to force ETag/Last-Modified revalidation on every load. Reserve `max-age=31536000, immutable` for content-hashed assets only. +103. **SPA fs.Stat Error Classification**: When `fs.Stat` fails for a static asset path, the SPA handler MUST check `errors.Is(err, fs.ErrNotExist)` and return 404 only for that case. Permission errors or I/O failures MUST return 500 (`http.StatusInternalServerError`). Blindly returning 404 for all stat errors masks filesystem corruption or misconfigured embed directives. diff --git a/backend/internal/platform/spa/handler.go b/backend/internal/platform/spa/handler.go index c3f2283..4d8b1df 100644 --- a/backend/internal/platform/spa/handler.go +++ b/backend/internal/platform/spa/handler.go @@ -2,6 +2,7 @@ package spa import ( + "errors" "io/fs" "net/http" "path/filepath" @@ -30,7 +31,11 @@ func NewSPAHandler(fsys fs.FS) http.Handler { _, err := fs.Stat(fsys, urlPath) if err != nil { if isStaticAsset(urlPath) { - http.NotFound(w, r) + if errors.Is(err, fs.ErrNotExist) { + http.NotFound(w, r) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } return } urlPath = "index.html" @@ -58,6 +63,10 @@ func setCacheHeaders(w http.ResponseWriter, filePath string) { w.Header().Set("Cache-Control", "no-cache") return } + if filepath.Ext(filePath) == ".wasm" { + w.Header().Set("Cache-Control", "public, no-cache") + return + } w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } diff --git a/backend/internal/platform/spa/handler_test.go b/backend/internal/platform/spa/handler_test.go index 2dacf26..9334868 100644 --- a/backend/internal/platform/spa/handler_test.go +++ b/backend/internal/platform/spa/handler_test.go @@ -208,6 +208,35 @@ func TestSPAHandler_MissingStaticAssetReturns404(t *testing.T) { } } +func TestSPAHandler_WASMCacheRevalidation(t *testing.T) { + handler := spa.NewSPAHandler(testFS()) + + req := httptest.NewRequest(http.MethodGet, "/inframap.wasm", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + expected := "public, no-cache" + if got := rec.Header().Get("Cache-Control"); got != expected { + t.Fatalf("expected %q for WASM cache, got %q", expected, got) + } +} + +func TestSPAHandler_StatErrorReturns500(t *testing.T) { + failFS := fstest.MapFS{ + "index.html": {Data: []byte("SPA")}, + } + failFS["broken.wasm"] = &fstest.MapFile{Data: []byte("data"), Mode: 0} + handler := spa.NewSPAHandler(failFS) + + req := httptest.NewRequest(http.MethodGet, "/missing.wasm", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for missing .wasm, got %d", rec.Code) + } +} + func TestSPAHandler_MissingIndexHTMLReturns404(t *testing.T) { emptyFS := fstest.MapFS{} handler := spa.NewSPAHandler(emptyFS) From 30bc212bb1ec29935880d413c3c94bb3742fc819 Mon Sep 17 00:00:00 2001 From: Matheus Henrique de Souza Date: Mon, 10 Aug 2026 19:53:39 -0300 Subject: [PATCH 6/6] fix(test): use custom fs.FS to properly test non-ErrNotExist stat error path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestSPAHandler_StatErrorReturns500 used fstest.MapFS with Mode: 0, which does not cause fs.Stat to fail. The test actually exercised the ErrNotExist → 404 path. Replace with a custom statErrorFS that returns a disk I/O error, ensuring the 500 branch is covered. --- backend/internal/platform/spa/handler_test.go | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/backend/internal/platform/spa/handler_test.go b/backend/internal/platform/spa/handler_test.go index 9334868..9c3da86 100644 --- a/backend/internal/platform/spa/handler_test.go +++ b/backend/internal/platform/spa/handler_test.go @@ -1,15 +1,55 @@ package spa_test import ( + "errors" "io" + "io/fs" "net/http" "net/http/httptest" "testing" "testing/fstest" + "time" "github.com/matheussouza/inframap/internal/platform/spa" ) +type statErrorFS struct { + err error +} + +func (f statErrorFS) Open(name string) (fs.File, error) { + if name == "index.html" { + return fstest.MapFS{"index.html": {Data: []byte("SPA")}}.Open("index.html") + } + return nil, f.err +} + +func (f statErrorFS) Stat(name string) (fs.FileInfo, error) { + if name == "index.html" { + return fileInfo{name: "index.html", size: 16}, nil + } + return nil, f.err +} + +func (f statErrorFS) ReadFile(name string) ([]byte, error) { + if name == "index.html" { + return []byte("SPA"), nil + } + return nil, f.err +} + +type fileInfo struct { + name string + size int64 +} + +func (fi fileInfo) Name() string { return fi.name } +func (fi fileInfo) Size() int64 { return fi.size } +func (fi fileInfo) Mode() fs.FileMode { return 0o444 } +func (fi fileInfo) ModTime() time.Time { return time.Time{} } +func (fi fileInfo) IsDir() bool { return false } +func (fi fileInfo) Sys() any { return nil } + func testFS() fstest.MapFS { return fstest.MapFS{ "index.html": {Data: []byte("SPA")}, @@ -222,18 +262,15 @@ func TestSPAHandler_WASMCacheRevalidation(t *testing.T) { } func TestSPAHandler_StatErrorReturns500(t *testing.T) { - failFS := fstest.MapFS{ - "index.html": {Data: []byte("SPA")}, - } - failFS["broken.wasm"] = &fstest.MapFile{Data: []byte("data"), Mode: 0} - handler := spa.NewSPAHandler(failFS) + fsys := statErrorFS{err: errors.New("disk I/O error")} + handler := spa.NewSPAHandler(fsys) - req := httptest.NewRequest(http.MethodGet, "/missing.wasm", nil) + req := httptest.NewRequest(http.MethodGet, "/app.wasm", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Fatalf("expected 404 for missing .wasm, got %d", rec.Code) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 for non-ErrNotExist stat error, got %d", rec.Code) } }