6 Commits

Author SHA1 Message Date
1412b2e0b7 Merge pull request 'feat: build :dev Docker image on push to dev' (#1) from chore/dev-staging-build into dev
All checks were successful
CI / test (push) Successful in 9m29s
CI / build-dev (push) Successful in 13s
Reviewed-on: #1
2026-03-28 10:38:48 -04:00
30b037ff9c feat: build :dev Docker image on push to dev
All checks were successful
CI / test (pull_request) Successful in 9m30s
CI / build-dev (pull_request) Has been skipped
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-<sha>    — 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 <noreply@anthropic.com>
2026-03-28 10:27:23 -04:00
7a5b5d7afc chore: establish dev branch and branching workflow
All checks were successful
CI / test (push) Successful in 9m26s
Merges the initial ci.yml + release.yml workflow changes onto dev.
This is the first merge under the new feature-branch → dev → main model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:16:00 -04:00
3383bee968 chore: replace build.yml with ci.yml + release.yml
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 <noreply@anthropic.com>
2026-03-28 10:15:52 -04:00
0c30e4bd29 chore: release v1.1.2
All checks were successful
Build / test (push) Successful in 9m27s
Build / build (push) Successful in 27s
Build / release (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:57 -04:00
01f83d25f6 fix: SPA deep-link assets and broken home screen CSS
Three root causes addressed:

1. Added <base href="/"> to index.html so all relative asset paths
   (css/app.css, js/*.js) resolve from the root regardless of the
   current SPA route. Without this, /instance/117 requested
   /instance/css/app.css, which hit the SPA fallback and returned
   HTML; helmet's nosniff then refused it as a stylesheet.

2. Removed upgrade-insecure-requests from the CSP (useDefaults: false).
   This directive told browsers to upgrade HTTP→HTTPS for every asset
   request, breaking all resource loading on HTTP-only deployments.

3. Changed script-src-attr from 'none' to 'unsafe-inline' to allow
   the inline onclick handlers used throughout the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:48 -04:00
7 changed files with 198 additions and 88 deletions

View File

@@ -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
}"

50
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
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
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 }}

View File

@@ -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
}"

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Catalyst</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -1,6 +1,6 @@
{
"name": "catalyst",
"version": "1.1.1",
"version": "1.1.2",
"type": "module",
"scripts": {
"start": "node server/server.js",

View File

@@ -11,10 +11,18 @@ export const app = express();
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false, // explicit — upgrade-insecure-requests breaks HTTP deployments
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
'style-src': ["'self'", 'https://fonts.googleapis.com'],
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'form-action': ["'self'"],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'"],
'script-src-attr': ["'unsafe-inline'"], // allow onclick handlers
'style-src': ["'self'", 'https://fonts.googleapis.com'],
},
},
}));

View File

@@ -237,3 +237,43 @@ describe('DELETE /api/instances/:vmid', () => {
expect(res.status).toBe(400)
})
})
// ── Static assets & SPA routing ───────────────────────────────────────────────
describe('static assets and SPA routing', () => {
it('serves index.html at root', async () => {
const res = await request(app).get('/')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/html/)
})
it('serves index.html for deep SPA routes (e.g. /instance/117)', async () => {
const res = await request(app).get('/instance/117')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/html/)
})
it('serves CSS with correct content-type (not sniffed as HTML)', async () => {
const res = await request(app).get('/css/app.css')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/css/)
})
it('does not set upgrade-insecure-requests in CSP (HTTP deployments must work)', async () => {
const res = await request(app).get('/')
const csp = res.headers['content-security-policy'] ?? ''
expect(csp).not.toContain('upgrade-insecure-requests')
})
it('allows inline event handlers in CSP (onclick attributes)', async () => {
const res = await request(app).get('/')
const csp = res.headers['content-security-policy'] ?? ''
// script-src-attr must not be 'none' — that blocks onclick handlers
expect(csp).not.toContain("script-src-attr 'none'")
})
it('index.html contains base href / for correct asset resolution on deep routes', async () => {
const res = await request(app).get('/')
expect(res.text).toContain('<base href="/">')
})
})