Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 28 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,35 @@ name: Release Pipeline

on:
push:
tags:
- 'v*.*.*'
pull_request:
types: [closed]
branches:
- develop
- main
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Semantic Version Tag (e.g. v1.0.0-rc.26)'
required: true
default: 'v1.0.0-rc.26'

concurrency:
group: release-pipeline
cancel-in-progress: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jobs:
release:
name: Build, Scan & Publish Release Artifacts
runs-on: ubuntu-latest
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true) ||
(github.event_name == 'push' &&
github.ref_type == 'tag') ||
(github.event_name == 'workflow_dispatch' &&
(github.ref_name == 'develop' || github.ref_name == 'main'))
permissions:
contents: write
packages: write
Expand All @@ -28,6 +41,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
Expand All @@ -36,15 +50,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"
Expand All @@ -59,9 +80,9 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
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 -c http.extraHeader="Authorization: Basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64)" 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]+$//')
Expand All @@ -78,10 +99,10 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
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 -c http.extraHeader="Authorization: Basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64)" push origin "$TAG"
fi
else
echo "Error: Unsupported ref $REF_NAME"
echo "Error: Unsupported ref $TARGET_BRANCH"
exit 1
fi

Expand Down
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -160,3 +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: 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.
22 changes: 22 additions & 0 deletions backend/internal/platform/spa/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package spa

import (
"errors"
"io/fs"
"net/http"
"path/filepath"
Expand Down Expand Up @@ -29,6 +30,14 @@ func NewSPAHandler(fsys fs.FS) http.Handler {

_, err := fs.Stat(fsys, urlPath)
if err != nil {
if isStaticAsset(urlPath) {
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
} else {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
urlPath = "index.html"
}

Expand All @@ -54,9 +63,22 @@ 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")
}

func isStaticAsset(filePath string) bool {
switch filepath.Ext(filePath) {
case ".wasm", ".js", ".css", ".json", ".map",
".svg", ".png", ".jpg", ".ico", ".woff", ".woff2", ".ttf":
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return true
}
return false
}

func setContentType(w http.ResponseWriter, filePath string) {
ext := filepath.Ext(filePath)
switch ext {
Expand Down
81 changes: 81 additions & 0 deletions backend/internal/platform/spa/handler_test.go
Original file line number Diff line number Diff line change
@@ -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("<html>SPA</html>")}}.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("<html>SPA</html>"), 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("<html>SPA</html>")},
Expand Down Expand Up @@ -193,6 +233,47 @@ 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_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) {
fsys := statErrorFS{err: errors.New("disk I/O error")}
handler := spa.NewSPAHandler(fsys)

req := httptest.NewRequest(http.MethodGet, "/app.wasm", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for non-ErrNotExist stat error, got %d", rec.Code)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestSPAHandler_MissingIndexHTMLReturns404(t *testing.T) {
emptyFS := fstest.MapFS{}
handler := spa.NewSPAHandler(emptyFS)
Expand Down
7 changes: 7 additions & 0 deletions frontend/webpack.config.d/wasm-assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
config.module.rules.push({
test: /\.wasm$/,
type: "asset/resource",
generator: {
filename: "[name][ext]"
}
});
Loading