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
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
VITE_APP_TITLE=Hexlode

# Dormant until cloud mode (ADR 0006). On Dokploy, the Postgres service's internal connection URL.
DATABASE_URL=postgresql://user:password@localhost:5432/hexlode

Expand All @@ -12,6 +10,8 @@ GOOGLE_CLIENT_SECRET=
VITE_POSTHOG_KEY=
VITE_POSTHOG_HOST=https://us.i.posthog.com
VITE_SENTRY_DSN=
VITE_SENTRY_ORG=
VITE_SENTRY_PROJECT=

# Build time only, for uploading source maps to Sentry. The token is a secret.
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
name: CI

# Every check runs on the pull request. `main` only receives squash merges of checked pull
# requests, so a push there runs the Docker image workflow instead of repeating these.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true

permissions:
contents: read
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
name: CodeQL

# Pull requests, plus a weekly run on `main` that keeps the baseline pull requests compare with.
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '17 4 * * 1'
Expand Down
58 changes: 40 additions & 18 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: Docker image

# Publishes ghcr.io/<owner>/hexlode for Dokploy to pull, then asks Dokploy to redeploy.
# `main` updates the `latest` tag; a `v1.2.3` tag also publishes `1.2.3` and `1.2`. Every image
# also gets a `sha-<commit>` tag to roll back to. See DEPLOY.md.
# Publishes ghcr.io/<owner>/hexlode. Every merge to `main` updates the `main` tag. A release, the
# `v1.2.3` tag Release Please starts this workflow for, publishes `latest`, `1.2.3` and `1.2`, then
# calls the Dokploy deploy webhook, since Dokploy runs `latest`. Every image also gets a
# `sha-<commit>` tag to roll back to. With the Sentry secret and variables set, the build uploads
# source maps. See DEPLOY.md.
on:
push:
branches: [main]
Expand Down Expand Up @@ -32,12 +34,24 @@ jobs:
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

# The release name the app reports to Sentry: the version for a release tag, else the commit.
- id: version
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "value=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi

- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
# `latest` only moves on a release, never on a plain merge to `main`.
flavor: latest=false
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=main,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
Expand All @@ -49,7 +63,12 @@ jobs:
platforms: linux/amd64
load: true
tags: hexlode:scan
build-args: HEXLODE_VERSION=${{ steps.meta.outputs.version }}
build-args: |
HEXLODE_VERSION=${{ steps.version.outputs.value }}
SENTRY_ORG=${{ vars.SENTRY_ORG }}
SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }}
secrets: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max

Expand Down Expand Up @@ -97,7 +116,14 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: HEXLODE_VERSION=${{ steps.meta.outputs.version }}
# The same arguments as the scan build, so the build stage comes from its cache and the
# source maps are uploaded once.
build-args: |
HEXLODE_VERSION=${{ steps.version.outputs.value }}
SENTRY_ORG=${{ vars.SENTRY_ORG }}
SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }}
secrets: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: mode=max
Expand All @@ -116,23 +142,19 @@ jobs:
deploy:
name: deploy to Dokploy
needs: image
# Only `main` deploys, and only once the Dokploy secrets are set (see DEPLOY.md).
if: github.ref == 'refs/heads/main'
# Only releases deploy, and only once the webhook secret is set (see DEPLOY.md).
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production
steps:
- name: Redeploy
- name: Call the deploy webhook
env:
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
DOKPLOY_APPLICATION_ID: ${{ secrets.DOKPLOY_APPLICATION_ID }}
DOKPLOY_WEBHOOK_URL: ${{ secrets.DOKPLOY_WEBHOOK_URL }}
run: |
if [ -z "$DOKPLOY_URL" ] || [ -z "$DOKPLOY_API_KEY" ] || [ -z "$DOKPLOY_APPLICATION_ID" ]; then
echo "::notice::Dokploy secrets are not set, so Dokploy was not asked to redeploy."
if [ -z "$DOKPLOY_WEBHOOK_URL" ]; then
echo "::notice::DOKPLOY_WEBHOOK_URL is not set, so Dokploy was not asked to deploy."
exit 0
fi
curl -fsS -X POST "${DOKPLOY_URL%/}/api/application.deploy" \
-H "x-api-key: $DOKPLOY_API_KEY" \
-H 'Content-Type: application/json' \
-d "{\"applicationId\": \"$DOKPLOY_APPLICATION_ID\"}"
curl -fsS --retry 3 --retry-all-errors -X POST "$DOKPLOY_WEBHOOK_URL"
echo
echo "Dokploy is deploying the new image."
39 changes: 39 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Release Please

# Keeps a release pull request open on `main` that bumps the version and writes CHANGELOG.md from
# the Conventional Commits merged since the last release. Merging it tags `vX.Y.Z`, creates the
# GitHub release and publishes the versioned image.
on:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: release-please
cancel-in-progress: false

jobs:
release:
name: release pull request
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
actions: write
steps:
- id: release
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

# Tags pushed with the workflow token start no workflows, so start the image build directly.
- name: Publish the versioned image
if: steps.release.outputs.release_created == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag_name }}
run: gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG"
3 changes: 1 addition & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
name: Security

# Dependency changes in pull requests, plus a daily audit of `main` for newly published advisories.
on:
push:
branches: [main]
pull_request:
paths:
- pnpm-lock.yaml
Expand Down
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ ".": "0.0.0" }
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ When the user changes a decision, update the document that owns it in the same c

## Stack

TanStack Start (React 19, Vite, Nitro), TypeScript, React Flow, Astryx with Tailwind, Motion, jSquash
TanStack Start (React 19, Vite, Nitro), TypeScript, React Flow, Astryx with Tailwind, Motion, GSAP, jSquash
codecs in Web Workers, OPFS, PostHog and Sentry. Drizzle, PostgreSQL and Better Auth are dormant
until cloud work: keep them compiling and build version 1 features without them.

Expand Down Expand Up @@ -64,6 +64,11 @@ Tailwind utilities such as `bg-surface`, `text-primary` and `rounded-lg`.
- Set colours, type and other tokens in `src/features/theme/hexlode-theme.ts`, then run
`pnpm theme:build`. Every colour needs a light and a dark value.
- Style the Studio canvas with the same tokens and hide the React Flow attribution.
- Nest corners: an inner corner is the outer corner minus the padding between them, such as a
`rounded-lg` (12px) card with 8px padding around `rounded` (4px) images. Astryx maps `rounded-xl`
to the 28px page radius, so keep it off cards.
- Animate interface elements with Motion. Direct home page scenes with GSAP through `useScene`
and the timings in `src/features/home/constants.ts` (ADR 0009).

## Commits

Expand Down
5 changes: 3 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ A ready-made pipeline offered when the Studio opens.
_Avoid_: preset, example

**Draft**:
The pipeline open in the Studio, kept in browser storage so a reload does not lose it. It holds
nodes, settings and the name, never images.
The pipeline open in a Studio tab, kept in that tab's session storage so a reload does not lose
it, while a new tab starts fresh. The last draft with unsaved changes is also kept in local storage
for a day, for a new tab to offer. It holds nodes, settings and the name, never images.
_Avoid_: autosave, backup

**Pipeline file**:
Expand Down
81 changes: 60 additions & 21 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ needs rebuilding to change a setting ([ADR 0008](./docs/adr/0008-one-image-confi

| Tag | Updated |
| --- | --- |
| `latest` | On every push to `main`. |
| `1.2.3`, `1.2` | When a `v1.2.3` tag is pushed. |
| `latest` | On every release. Production runs this tag. |
| `1.2.3`, `1.2` | On every release: merging the Release Please pull request tags `v1.2.3`. |
| `main` | On every merge to `main`, released or not. Use it to try unreleased changes. |
| `sha-abc1234` | On every build. Use it to pin or roll back to one commit. |

The container listens on port `3000`, runs as an unprivileged user, and answers `GET /api/health`
Expand Down Expand Up @@ -43,18 +44,27 @@ runs with analytics and error reports off.
| --- | --- |
| `VITE_POSTHOG_KEY` | PostHog project key. Turns on cookieless analytics. |
| `VITE_POSTHOG_HOST` | PostHog API host, for example `https://eu.i.posthog.com`. Defaults to the US host. |
| `VITE_SENTRY_DSN` | Sentry DSN. Turns on error reports in the browser and on the server. |
| `VITE_SENTRY_DSN` | Sentry DSN. Turns on error reports, logs and tracing in the browser and on the server. |
| `PORT` | The port the server listens on. Defaults to `3000`; change the domain's port to match. |

The `VITE_*` values are public: they reach every visitor's browser. Never put a secret in a
variable that starts with `VITE_`. A change takes effect on the next deploy or restart.
variable that starts with `VITE_`. A change takes effect on the next deploy or restart. The image
sets `HEXLODE_VERSION` itself; Sentry and PostHog label reports and events with it.

The Sentry organisation, project and auth token are not runtime settings. They are only needed
where the image is built, to upload source maps; see [Readable Sentry stack traces](#readable-sentry-stack-traces).

Hexlode sends PostHog cookieless events, so in the PostHog project turn on **cookieless server
hash mode** and **Discard client IP data**. Without the first, PostHog accepts the events and then
drops them. The app sends only its own named events, such as `page_viewed`, and no `$pageview`, so
look for them under **Activity → Events**; the Web analytics dashboard stays empty. PostHog's
onboarding snippet `posthog.capture(…)` does not work in the console, because the app does not put
PostHog on `window`.
hash mode** (**Project settings → Web analytics**) and **Discard client IP data**. Without the
first, PostHog answers `200 OK` and then drops the events. PostHog also answers `200 OK` for a
wrong project key, or for a key sent to the other region's host, so check that `VITE_POSTHOG_KEY`
is the project's key and `VITE_POSTHOG_HOST` matches its region (`us` or `eu`).

PostHog records `$pageview`, `$pageleave`, clicks, heatmaps and web vitals by itself, so the Web
analytics dashboard fills in. The app's own events, such as `run_started` and `node_added`, are
under **Activity → Events**. PostHog's onboarding snippet `posthog.capture(…)` does not work in
the console, because the app does not put PostHog on `window`; open a page with
`?__posthog_debug=true` to see what PostHog sends.

### 4. Add the domain

Expand Down Expand Up @@ -97,24 +107,52 @@ runs Node directly:

Then click **Deploy**. Open `https://your-domain/api/health` to see the running version.

## Deploying on every push to `main`
## Deploying on every release

The `Docker image` workflow asks Dokploy to redeploy once the new image is pushed. Give it three
secrets in GitHub (**Settings → Secrets and variables → Actions**, as repository secrets or in the
`production` environment):
Merging a pull request into `main` publishes the `main` image but deploys nothing. Merging the
Release Please pull request makes a release: the `Docker image` workflow publishes `latest` and the
version tags, then calls the application's Dokploy deploy webhook, and Dokploy pulls `latest` and
redeploys. Copy the webhook from the application's
**Deployments** tab in Dokploy and save it in GitHub (**Settings → Secrets and variables →
Actions**) as the repository secret `DOKPLOY_WEBHOOK_URL`. Until it is set, the workflow publishes
the image and skips the deploy.

| Secret | Value |
| --- | --- |
| `DOKPLOY_URL` | Your Dokploy address, for example `https://dokploy.example.com`. |
| `DOKPLOY_API_KEY` | A token from Dokploy's `/settings/profile` page, **API/CLI** section. |
| `DOKPLOY_APPLICATION_ID` | The application's ID: the last part of its address in Dokploy. |
## Readable Sentry stack traces

The build uploads source maps to Sentry, then removes them from the image, when it has these
values. Add them in GitHub under **Settings → Secrets and variables → Actions**:

| Name | Kind | Value |
| --- | --- | --- |
| `SENTRY_AUTH_TOKEN` | Secret | An organisation token from Sentry: **Settings → Developer Settings → Organization Tokens**. |
| `SENTRY_ORG` | Variable | The organisation slug, from the Sentry address: `https://<org>.sentry.io`. |
| `SENTRY_PROJECT` | Variable | The project slug, from **Settings → Projects**. |

Without them the image builds the same and Sentry shows minified stack traces.

## Monitoring with Sentry

With `VITE_SENTRY_DSN` set, Sentry receives:

- **Errors** from the browser, server requests and server functions, with file names removed.
- **Logs**: warnings and errors the app writes to the console, with file names removed.
- **Traces** of a fifth of page loads, navigations and server requests, under **Explore → Traces**
and **Insights**.

Browser reports go to a same-origin route the build generates, which forwards them to Sentry, so
content blockers do not drop them. Session replay stays off.

Set these up in Sentry itself:

Until they are set, the workflow publishes the image and skips the deploy.
- **Uptime monitor** (**Insights → Uptime**): check `https://your-domain/api/health` so Sentry
alerts you when the site is down.
- **Alerts** (**Alerts → Create alert**): for example, email on every new issue, or when errors in
an hour pass a number.

## Rolling back

Change the image on the **General** tab to an earlier `sha-…` tag and deploy. Every published tag
is listed on the package's GitHub page. Switch back to `latest` to follow `main` again.
is listed on the package's GitHub page. Switch back to `latest` to follow releases again.

## Building on the server instead

Expand All @@ -140,5 +178,6 @@ the same image and the same kind of settings:
## Running the image anywhere

```bash
docker run -p 3000:3000 -e VITE_POSTHOG_KEY=phc_… ghcr.io/pixelactstudio/hexlode:latest
docker run -p 3000:3000 -e VITE_POSTHOG_KEY=phc_… -e VITE_SENTRY_DSN=https://… \
ghcr.io/pixelactstudio/hexlode:latest
```
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build && chmod -R a+rX .output
# With a Sentry auth token (a build secret), the build uploads source maps and removes them from the
# output. Without one it skips the upload.
ARG HEXLODE_VERSION=dev
ARG SENTRY_ORG
ARG SENTRY_PROJECT
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN,env=SENTRY_AUTH_TOKEN \
SENTRY_RELEASE="$HEXLODE_VERSION" pnpm build && chmod -R a+rX .output
# Nitro leaves Sentry out of the server bundle, so install it on its own at the locked version.
RUN SENTRY=$(node -p "require('@sentry/tanstackstart-react/package.json').version") \
&& npm install --prefix /runtime --omit=dev --omit=optional --ignore-scripts \
Expand Down
Loading
Loading