From 3383bee968a0298a9147bb052daaf4c1591d7a0e Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 10:15:52 -0400 Subject: [PATCH 01/10] chore: replace build.yml with ci.yml + release.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the single workflow into two with distinct responsibilities: ci.yml — runs tests on push/PR to dev and main. Powers the required status check for branch protection on both branches. release.yml — triggers on push to main (merged PR). Reads version from package.json, asserts the tag doesn't already exist, creates the git tag, generates patch notes from commits since the previous tag, builds and pushes the Docker image, and creates the Gitea release. No more manual git tag or git push --tags. build.yml deleted — all three of its jobs are covered by the new files. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 84 ------------------------------- .gitea/workflows/ci.yml | 23 +++++++++ .gitea/workflows/release.yml | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 84 deletions(-) delete mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml deleted file mode 100644 index 5f168e1..0000000 --- a/.gitea/workflows/build.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Build - -on: - push: - branches: [main] - tags: - - 'v*' - -env: - IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test - - build: - runs-on: ubuntu-latest - needs: test - if: startsWith(gitea.ref, 'refs/tags/v') - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=,format=short - type=raw,value=latest,enable={{is_default_branch}} - - - name: Log in to Gitea registry - uses: docker/login-action@v3 - with: - registry: ${{ vars.REGISTRY_HOST }} - username: ${{ gitea.actor }} - password: ${{ secrets.TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - - release: - runs-on: ubuntu-latest - needs: build - if: startsWith(gitea.ref, 'refs/tags/v') - - steps: - - name: Create release - run: | - curl -sf -X POST \ - -H "Authorization: token ${{ secrets.TOKEN }}" \ - -H "Content-Type: application/json" \ - "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \ - -d "{ - \"tag_name\": \"${{ gitea.ref_name }}\", - \"name\": \"Catalyst ${{ gitea.ref_name }}\", - \"body\": \"### Image\n\n\`${{ env.IMAGE }}:${{ gitea.ref_name }}\`\", - \"draft\": false, - \"prerelease\": false - }" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..856da66 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: npm + + - run: npm ci + + - run: npm test diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..6e08a87 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + branches: [main] + +env: + IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: npm + + - run: npm ci + - run: npm test + + - name: Read version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "VERSION=${VERSION}" >> $GITEA_ENV + + - name: Assert tag does not exist + run: | + if git ls-remote --tags origin "refs/tags/v${{ env.VERSION }}" | grep -q .; then + echo "ERROR: tag v${{ env.VERSION }} already exists — bump version in package.json before merging to main." + exit 1 + fi + + - name: Create and push tag + run: | + git config user.name "gitea-actions" + git config user.email "actions@gitea" + git tag "v${{ env.VERSION }}" + git push origin "v${{ env.VERSION }}" + + - name: Generate release notes + run: | + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$LAST_TAG" ]; then + NOTES=$(git log "${LAST_TAG}..HEAD" --pretty=format:"- %s" --no-merges) + else + NOTES=$(git log --pretty=format:"- %s" --no-merges) + fi + NOTES_JSON=$(printf '%s' "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") + echo "NOTES=${NOTES_JSON}" >> $GITEA_ENV + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=semver,pattern={{version}},value=v${{ env.VERSION }} + type=semver,pattern={{major}}.{{minor}},value=v${{ env.VERSION }} + type=sha,prefix=,format=short + type=raw,value=latest + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_HOST }} + username: ${{ gitea.actor }} + password: ${{ secrets.TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + + - name: Create Gitea release + run: | + curl -sf -X POST \ + -H "Authorization: token ${{ secrets.TOKEN }}" \ + -H "Content-Type: application/json" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \ + -d "{ + \"tag_name\": \"v${{ env.VERSION }}\", + \"name\": \"Catalyst v${{ env.VERSION }}\", + \"body\": \"### Changes\n\n${{ env.NOTES }}\n\n### Image\n\n\`${{ env.IMAGE }}:${{ env.VERSION }}\`\", + \"draft\": false, + \"prerelease\": false + }" From 30b037ff9c8fc93ac3cfb1b63c927cf4350ec3f2 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 10:27:23 -0400 Subject: [PATCH 02/10] feat: build :dev Docker image on push to dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a build-dev job to ci.yml that fires after tests pass on direct pushes to dev (not PRs). Pushes two tags to the registry: :dev — mutable, always the latest integrated dev state :dev- — immutable, for tracing exactly which commit is running Staging servers can pull :dev to test before a release PR is opened. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 856da66..f2f4275 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [dev, main] +env: + IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst + jobs: test: runs-on: ubuntu-latest @@ -21,3 +24,27 @@ jobs: - run: npm ci - run: npm test + + build-dev: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + + steps: + - uses: actions/checkout@v4 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_HOST }} + username: ${{ gitea.actor }} + password: ${{ secrets.TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.IMAGE }}:dev + ${{ env.IMAGE }}:dev-${{ gitea.sha }} From 15ed329743046b07102a0456d4782fe2ee3c43dc Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 11:11:00 -0400 Subject: [PATCH 03/10] fix: db volume ownership and explicit error handling for write failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 500 on create/update/delete: the non-root app user in the Docker container lacked write permission to the volume mount point. Docker volume mounts are owned by root by default; the app user (added in a previous commit) could read the database but not write to it. Fixes: 1. Dockerfile — RUN mkdir -p /app/data before chown so the directory exists in the image with correct ownership. Docker uses this as a seed when initialising a new named volume, ensuring the app user owns the mount point from the start. NOTE: existing volumes from before the non-root user was introduced will still be root-owned. Fix with: docker run --rm -v catalyst-data:/data alpine chown -R 1000:1000 /data 2. server/routes.js — replace bare `throw e` in POST/PUT catch blocks with console.error (route context + error) + explicit 500 response. Add try-catch to DELETE handler which previously had none. Unexpected DB errors now log the route they came from and return a clean JSON body instead of relying on the generic Express error handler. 3. server/db.js — wrap the boot init() call in try-catch. Fatal startup errors (e.g. data directory not writable) now print a clear message pointing to the cause before exiting, instead of a raw stack trace. TDD: tests written first (RED), then fixed (GREEN). Six new tests in tests/api.test.js verify that unexpected DB errors on POST, PUT, and DELETE return 500 with { error: 'internal server error' } and call console.error with the route context string. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- server/db.js | 10 ++++++- server/routes.js | 15 +++++++--- tests/api.test.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6c6bcd3..608e6f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ COPY . . RUN awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \ package.json > js/version.js -RUN chown -R app:app /app +RUN mkdir -p /app/data && chown -R app:app /app USER app EXPOSE 3000 diff --git a/server/db.js b/server/db.js index f7a0fb9..6d221cc 100644 --- a/server/db.js +++ b/server/db.js @@ -134,4 +134,12 @@ export function _resetForTest() { // ── Boot ────────────────────────────────────────────────────────────────────── -init(process.env.DB_PATH ?? DEFAULT_PATH); +const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH; +try { + init(DB_PATH); +} catch (e) { + console.error('[catalyst] fatal: could not open database at', DB_PATH); + console.error('[catalyst] ensure the data directory exists and is writable by the server process.'); + console.error(e); + process.exit(1); +} diff --git a/server/routes.js b/server/routes.js index c75b578..79eaad6 100644 --- a/server/routes.js +++ b/server/routes.js @@ -78,7 +78,8 @@ router.post('/instances', (req, res) => { } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); - throw e; + console.error('POST /api/instances', e); + res.status(500).json({ error: 'internal server error' }); } }); @@ -98,7 +99,8 @@ router.put('/instances/:vmid', (req, res) => { } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); - throw e; + console.error('PUT /api/instances/:vmid', e); + res.status(500).json({ error: 'internal server error' }); } }); @@ -112,6 +114,11 @@ router.delete('/instances/:vmid', (req, res) => { if (instance.stack !== 'development') return res.status(422).json({ error: 'only development instances can be deleted' }); - deleteInstance(vmid); - res.status(204).end(); + try { + deleteInstance(vmid); + res.status(204).end(); + } catch (e) { + console.error('DELETE /api/instances/:vmid', e); + res.status(500).json({ error: 'internal server error' }); + } }); diff --git a/tests/api.test.js b/tests/api.test.js index 38138ab..c807c30 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import request from 'supertest' import { app } from '../server/server.js' import { _resetForTest } from '../server/db.js' +import * as dbModule from '../server/db.js' beforeEach(() => _resetForTest()) @@ -277,3 +278,74 @@ describe('static assets and SPA routing', () => { expect(res.text).toContain('') }) }) + +// ── Error handling — unexpected DB failures ─────────────────────────────────── + +const dbError = () => Object.assign( + new Error('attempt to write a readonly database'), + { code: 'ERR_SQLITE_ERROR', errcode: 8 } +) + +describe('error handling — unexpected DB failures', () => { + let consoleSpy + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('POST returns 500 with friendly message when DB throws unexpectedly', async () => { + vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() }) + const res = await request(app).post('/api/instances').send(base) + expect(res.status).toBe(500) + expect(res.body).toEqual({ error: 'internal server error' }) + }) + + it('POST logs the error with route context when DB throws unexpectedly', async () => { + vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() }) + await request(app).post('/api/instances').send(base) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('POST /api/instances'), + expect.any(Error) + ) + }) + + it('PUT returns 500 with friendly message when DB throws unexpectedly', async () => { + await request(app).post('/api/instances').send(base) + vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() }) + const res = await request(app).put('/api/instances/100').send(base) + expect(res.status).toBe(500) + expect(res.body).toEqual({ error: 'internal server error' }) + }) + + it('PUT logs the error with route context when DB throws unexpectedly', async () => { + await request(app).post('/api/instances').send(base) + vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() }) + await request(app).put('/api/instances/100').send(base) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('PUT /api/instances/:vmid'), + expect.any(Error) + ) + }) + + it('DELETE returns 500 with friendly message when DB throws unexpectedly', async () => { + await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' }) + vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() }) + const res = await request(app).delete('/api/instances/100') + expect(res.status).toBe(500) + expect(res.body).toEqual({ error: 'internal server error' }) + }) + + it('DELETE logs the error with route context when DB throws unexpectedly', async () => { + await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' }) + vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() }) + await request(app).delete('/api/instances/100') + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('DELETE /api/instances/:vmid'), + expect.any(Error) + ) + }) +}) From 6c04a30c3ae0e1b0f9719c26e208e650aa0d9390 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 11:31:55 -0400 Subject: [PATCH 04/10] fix: skip db boot init in test env to prevent parallel worker lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest runs test files in parallel workers. Each worker imports server/db.js, which triggered module-level init(DEFAULT_PATH) unconditionally. Two workers racing to open the same SQLite file caused "database is locked", followed by process.exit(1) killing the worker — surfacing as: Error: process.exit unexpectedly called with "1" Fix: guard the boot init block behind NODE_ENV !== 'test'. Vitest sets NODE_ENV=test automatically. Each worker's beforeEach(() => _resetForTest()) initialises its own :memory: database, so no file coordination is needed. process.exit(1) is also guarded by the same condition — it must never fire inside a test runner process. TDD: two regression tests added to tests/db.test.js documenting the expected boot behaviour and proving the module loads cleanly in parallel. Co-Authored-By: Claude Sonnet 4.6 --- server/db.js | 15 ++++++++++++++- tests/db.test.js | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/server/db.js b/server/db.js index f7a0fb9..b88e974 100644 --- a/server/db.js +++ b/server/db.js @@ -133,5 +133,18 @@ export function _resetForTest() { } // ── Boot ────────────────────────────────────────────────────────────────────── +// Skipped in test environment — parallel Vitest workers would race to open +// the same file, causing "database is locked". _resetForTest() in beforeEach +// handles initialisation for every test worker using :memory: instead. -init(process.env.DB_PATH ?? DEFAULT_PATH); +if (process.env.NODE_ENV !== 'test') { + const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH; + try { + init(DB_PATH); + } catch (e) { + console.error('[catalyst] fatal: could not open database at', DB_PATH); + console.error('[catalyst] ensure the data directory exists and is writable by the server process.'); + console.error(e); + process.exit(1); + } +} diff --git a/tests/db.test.js b/tests/db.test.js index a61073e..746f2b5 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -165,3 +165,23 @@ describe('deleteInstance', () => { expect(getInstance(2)).not.toBeNull(); }); }); + +// ── Test environment boot isolation ─────────────────────────────────────────── + +describe('test environment boot isolation', () => { + it('vitest runs with NODE_ENV=test', () => { + // Vitest sets NODE_ENV=test automatically. This is the guard condition + // that prevents the boot init() from opening the real database file. + expect(process.env.NODE_ENV).toBe('test'); + }); + + it('db module loads cleanly in parallel workers without locking the real db file', () => { + // Regression: the module-level init(DEFAULT_PATH) used to run unconditionally, + // causing "database is locked" when multiple test workers imported db.js at + // the same time. process.exit(1) then killed the worker mid-suite. + // Fix: boot init is skipped when NODE_ENV=test. _resetForTest() handles setup. + // Reaching this line proves the module loaded without calling process.exit. + expect(() => _resetForTest()).not.toThrow(); + expect(getInstances()).toEqual([]); + }); +}); From 08c12c9394e10582e2a424ba2c151e531fa8c231 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 11:31:55 -0400 Subject: [PATCH 05/10] fix: skip db boot init in test env to prevent parallel worker lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest runs test files in parallel workers. Each worker imports server/db.js, which triggered module-level init(DEFAULT_PATH) unconditionally. Two workers racing to open the same SQLite file caused "database is locked", followed by process.exit(1) killing the worker — surfacing as: Error: process.exit unexpectedly called with "1" Fix: guard the boot init block behind NODE_ENV !== 'test'. Vitest sets NODE_ENV=test automatically. Each worker's beforeEach(() => _resetForTest()) initialises its own :memory: database, so no file coordination is needed. process.exit(1) is also guarded by the same condition — it must never fire inside a test runner process. TDD: two regression tests added to tests/db.test.js documenting the expected boot behaviour and proving the module loads cleanly in parallel. Co-Authored-By: Claude Sonnet 4.6 --- server/db.js | 21 +++++++++++++-------- tests/db.test.js | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/server/db.js b/server/db.js index 6d221cc..b88e974 100644 --- a/server/db.js +++ b/server/db.js @@ -133,13 +133,18 @@ export function _resetForTest() { } // ── Boot ────────────────────────────────────────────────────────────────────── +// Skipped in test environment — parallel Vitest workers would race to open +// the same file, causing "database is locked". _resetForTest() in beforeEach +// handles initialisation for every test worker using :memory: instead. -const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH; -try { - init(DB_PATH); -} catch (e) { - console.error('[catalyst] fatal: could not open database at', DB_PATH); - console.error('[catalyst] ensure the data directory exists and is writable by the server process.'); - console.error(e); - process.exit(1); +if (process.env.NODE_ENV !== 'test') { + const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH; + try { + init(DB_PATH); + } catch (e) { + console.error('[catalyst] fatal: could not open database at', DB_PATH); + console.error('[catalyst] ensure the data directory exists and is writable by the server process.'); + console.error(e); + process.exit(1); + } } diff --git a/tests/db.test.js b/tests/db.test.js index a61073e..746f2b5 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -165,3 +165,23 @@ describe('deleteInstance', () => { expect(getInstance(2)).not.toBeNull(); }); }); + +// ── Test environment boot isolation ─────────────────────────────────────────── + +describe('test environment boot isolation', () => { + it('vitest runs with NODE_ENV=test', () => { + // Vitest sets NODE_ENV=test automatically. This is the guard condition + // that prevents the boot init() from opening the real database file. + expect(process.env.NODE_ENV).toBe('test'); + }); + + it('db module loads cleanly in parallel workers without locking the real db file', () => { + // Regression: the module-level init(DEFAULT_PATH) used to run unconditionally, + // causing "database is locked" when multiple test workers imported db.js at + // the same time. process.exit(1) then killed the worker mid-suite. + // Fix: boot init is skipped when NODE_ENV=test. _resetForTest() handles setup. + // Reaching this line proves the module loaded without calling process.exit. + expect(() => _resetForTest()).not.toThrow(); + expect(getInstances()).toEqual([]); + }); +}); From 0f2a37cb3907ababcfe0159dd6224d87dbac81d0 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 12:28:44 -0400 Subject: [PATCH 06/10] fix: centre badge text on instance cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .badge lacked text-align: center. Inside the card's flex-end right column, badge text was left-justified within each pill, making state labels (deployed / testing / degraded) appear skewed to the left. TDD: CSS regression test added to tests/helpers.test.js — reads css/app.css directly and asserts the rule is present, so this cannot regress silently in future. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 1 + tests/helpers.test.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/css/app.css b/css/app.css index eff8043..d21a23a 100644 --- a/css/app.css +++ b/css/app.css @@ -289,6 +289,7 @@ select:focus { border-color: var(--accent); } border-radius: 3px; letter-spacing: 0.08em; text-transform: uppercase; + text-align: center; } .badge.deployed { background: var(--accent2); color: var(--accent); } diff --git a/tests/helpers.test.js b/tests/helpers.test.js index dc3a809..69008a6 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -1,5 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { join } from 'path' // ── esc() ───────────────────────────────────────────────────────────────────── // Mirrors the implementation in ui.js exactly (DOM-based). @@ -112,3 +114,16 @@ describe('fmtDateFull', () => { expect(fmtDateFull('')).toBe('—') }) }) + +// ── CSS regressions ─────────────────────────────────────────────────────────── + +const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8') + +describe('CSS regressions', () => { + it('.badge has text-align: center so state labels are not left-skewed on cards', () => { + // Regression: badges rendered left-aligned inside the card's flex-end column. + // Without text-align: center, short labels (e.g. "deployed") appear + // left-justified inside their pill rather than centred. + expect(css).toMatch(/\.badge\s*\{[^}]*text-align\s*:\s*center/s) + }) +}) From 6ba02bf17de2fef50314a244c6a97379cfe9b7df Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 12:52:15 -0400 Subject: [PATCH 07/10] feat: show dev- version string in nav for dev builds Production images continue to display the semver (v1.x.x). Dev images built by CI now receive BUILD_VERSION=dev-<7-char-sha> via a Docker ARG, and app.js skips the v prefix for non-semver strings. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 4 ++++ Dockerfile | 9 +++++++-- js/app.js | 5 ++++- tests/helpers.test.js | 30 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f2f4275..914e430 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -40,11 +40,15 @@ jobs: username: ${{ gitea.actor }} password: ${{ secrets.TOKEN }} + - name: Compute short SHA + run: echo "SHORT_SHA=${GITEA_SHA::7}" >> $GITEA_ENV + - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true + build-args: BUILD_VERSION=dev-${{ env.SHORT_SHA }} tags: | ${{ env.IMAGE }}:dev ${{ env.IMAGE }}:dev-${{ gitea.sha }} diff --git a/Dockerfile b/Dockerfile index 608e6f6..0a95f88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,13 @@ COPY package*.json ./ RUN npm ci --omit=dev COPY . . -RUN awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \ - package.json > js/version.js +ARG BUILD_VERSION="" +RUN if [ -n "$BUILD_VERSION" ]; then \ + printf 'const VERSION = "%s";\n' "$BUILD_VERSION" > js/version.js; \ + else \ + awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \ + package.json > js/version.js; \ + fi RUN mkdir -p /app/data && chown -R app:app /app USER app diff --git a/js/app.js b/js/app.js index 72cc2b0..1380b38 100644 --- a/js/app.js +++ b/js/app.js @@ -38,6 +38,9 @@ window.addEventListener('popstate', e => { // ── Bootstrap ───────────────────────────────────────────────────────────────── -if (VERSION) document.getElementById('nav-version').textContent = `v${VERSION}`; +if (VERSION) { + const label = /^\d/.test(VERSION) ? `v${VERSION}` : VERSION; + document.getElementById('nav-version').textContent = label; +} handleRoute(); diff --git a/tests/helpers.test.js b/tests/helpers.test.js index 69008a6..a2af546 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -115,6 +115,24 @@ describe('fmtDateFull', () => { }) }) +// ── versionLabel() ─────────────────────────────────────────────────────────── +// Mirrors the logic in app.js — semver strings get a v prefix, dev strings don't. + +function versionLabel(v) { + return /^\d/.test(v) ? `v${v}` : v +} + +describe('version label formatting', () => { + it('prepends v for semver strings', () => { + expect(versionLabel('1.1.2')).toBe('v1.1.2') + expect(versionLabel('2.0.0')).toBe('v2.0.0') + }) + + it('does not prepend v for dev build strings', () => { + expect(versionLabel('dev-abc1234')).toBe('dev-abc1234') + }) +}) + // ── CSS regressions ─────────────────────────────────────────────────────────── const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8') @@ -127,3 +145,15 @@ describe('CSS regressions', () => { expect(css).toMatch(/\.badge\s*\{[^}]*text-align\s*:\s*center/s) }) }) + +// ── CI workflow regressions ─────────────────────────────────────────────────── + +const ciYml = readFileSync(join(__dirname, '../.gitea/workflows/ci.yml'), 'utf8') + +describe('CI workflow regressions', () => { + it('build-dev job passes BUILD_VERSION build arg', () => { + // Regression: dev image showed semver instead of dev- because + // BUILD_VERSION was never passed to docker build. + expect(ciYml).toContain('BUILD_VERSION') + }) +}) From cae0f2222a831a104488de63957a64f43aa67bfd Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 13:11:05 -0400 Subject: [PATCH 08/10] fix: remove npm cache from setup-node The Gitea runner's cache service is unreachable, causing a ~4 minute ETIMEDOUT on every run before falling back to a cold install anyway. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 914e430..929db22 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -19,7 +19,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 'lts/*' - cache: npm - run: npm ci From 65d651460363a6a4516bbde7a166e58a114e03ef Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 13:18:47 -0400 Subject: [PATCH 09/10] fix: use git rev-parse for short SHA in build-dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $GITEA_SHA is unset on Gitea runners — the nav showed "dev-" with an empty SHA. git rev-parse --short HEAD works regardless of runner env vars. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 2 +- tests/helpers.test.js | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 929db22..cb05809 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: password: ${{ secrets.TOKEN }} - name: Compute short SHA - run: echo "SHORT_SHA=${GITEA_SHA::7}" >> $GITEA_ENV + run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITEA_ENV - name: Build and push uses: docker/build-push-action@v5 diff --git a/tests/helpers.test.js b/tests/helpers.test.js index a2af546..26bdd4d 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -156,4 +156,11 @@ describe('CI workflow regressions', () => { // BUILD_VERSION was never passed to docker build. expect(ciYml).toContain('BUILD_VERSION') }) + + it('short SHA is computed with git rev-parse, not $GITEA_SHA (which is empty)', () => { + // Regression: ${GITEA_SHA::7} expands to "" on Gitea runners — nav showed "dev-". + // git rev-parse --short HEAD works regardless of which env vars the runner sets. + expect(ciYml).toContain('git rev-parse --short HEAD') + expect(ciYml).not.toContain('GITEA_SHA') + }) }) From e54c1d484868fc34988b2d374cbafb316e0d5f05 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 13:21:18 -0400 Subject: [PATCH 10/10] chore: bump version to 1.2.0 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5429248..9ca8736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalyst", - "version": "1.1.2", + "version": "1.2.0", "type": "module", "scripts": { "start": "node server/server.js",