diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d375702..7deb747 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,13 @@ name: Release Pipeline on: push: + tags: + - 'v*.*.*' + pull_request: + types: [closed] branches: - develop - main - tags: - - 'v*.*.*' workflow_dispatch: inputs: version: @@ -14,10 +16,21 @@ on: required: true default: 'v1.0.0-rc.26' +concurrency: + 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) || + (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 @@ -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 @@ -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" @@ -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]+$//') @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index a7d3485..6b5edbc 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,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. diff --git a/backend/internal/platform/spa/handler.go b/backend/internal/platform/spa/handler.go index 4115288..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" @@ -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 + } urlPath = "index.html" } @@ -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": + 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..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")}, @@ -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) + } +} + func TestSPAHandler_MissingIndexHTMLReturns404(t *testing.T) { emptyFS := fstest.MapFS{} handler := spa.NewSPAHandler(emptyFS) 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]" + } +});