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
33 changes: 29 additions & 4 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
**/node_modules
**/.next
**/dist
**/build
**/release-dist
apps/desktop/out
apps/desktop/.vite
Expand All @@ -16,10 +17,34 @@ apps/desktop/.vite
.gitignore
.github

# Secrets / local env — MUST NOT enter the build context or image
.env
.env.*
!.env.example
# Claude worktrees — full duplicate checkouts of this repo (multi-GB), each
# carrying its own tracked env files. Nothing builds from them.
.claude/worktrees

# Secrets / local env — MUST NOT enter the build context or image.
#
# The `**/` prefixes are load-bearing: a pattern without one is matched only
# against the CONTEXT-ROOT-relative path, so the previous `.env` / `.env.*`
# covered a root-level `.env` and nothing deeper. Every per-app env file was
# therefore in the context of every image. Two consequences, both real:
#
# • `docker build -f apps/api/Dockerfile .` from a working checkout shipped
# the operator's own apps/api/.env — DB URL, auth secret, provider keys —
# into openship-api, because Dockerfile:10 is `COPY apps/ ./apps/` and the
# runtime stage copies apps/api forward. Same exposure via
# apps/dashboard/Dockerfile. CI escaped it only because a clean checkout
# has no untracked .env.
# • apps/email/client/.env.development reached the webmail builder, where
# `node` is bun and bun auto-loads it whenever NODE_ENV is unset — which is
# how GH-567 froze `http://localhost:3000` into the published client
# bundle. (Also fixed at the source in apps/email/scripts/build-release.ts;
# this is the second lock on the same door.)
#
# No Dockerfile copies a `.env*` out of the context — every env COPY is a
# `--from=builder` — so excluding them cannot break a build.
**/.env
**/.env.*
!**/.env.example

# Caches + logs
**/.turbo
Expand Down
72 changes: 32 additions & 40 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ on:
pull_request: {}
push:
branches: [main]
# A tag is what actually ships; the e2e-docker job runs on tags (see its
# `if` gate) so a release is proven restorable before it goes out.
# Tags run typecheck + tests here for the record; nothing in THIS file gates a
# release, because workflows are not ordered against one another. The checks that
# BLOCK a publish — this same suite plus the real-daemon E2E matrix, which used to
# live in this file and only ever ran alongside the release it claimed to gate —
# are in release-gate.yml, which Release and Docker images both list in `needs:`.
tags: ["v*"]
workflow_dispatch: {}

Expand Down Expand Up @@ -86,35 +89,28 @@ jobs:
# script (@repo/core, @repo/adapters, @repo/db [PGlite — no external DB],
# apps/api, apps/dashboard). Packages resolve to src, so no build needed.
# apps/api excludes test/e2e/** here — those need a daemon and run in the
# e2e-docker job below.
# e2e-docker job in release-gate.yml, where they gate the publish.
- name: Run tests
run: bun run test

# Rollback and restore against a REAL Docker daemon. This is the only job that
# proves those paths work at all; every other test in the repo mocks the runtime.
# The webmail server's own suite, which nothing else runs.
#
# MANUAL by design: runs only on a manual `workflow_dispatch` and on release
# tag pushes (v*) — never on main pushes or PRs, which stay fast (typecheck +
# tests). The tag run is the release-restorability gate.
# GH-220: apps/email/server/test/{sanitize,from-header,list-snippet}.test.ts had no
# runner at all. They are `bun:test` files, and the root `test` script is
# `turbo run test --filter=!@repo/email` → vitest, so turbo never reached them; the
# server is not a root workspace member either (workspaces is apps/* + packages/*,
# which matches apps/email but not its subdirectories), so its deps are not installed
# by the root install. Net effect: 35 assertions were green on someone's laptop and
# unreachable from every pipeline — including sanitize.test.ts, which pins the fix for
# the CSS url()/@import read-receipt leak (GHSA-3hcp-c4c7-6m8p) and asserts the read
# pane stays inert. That is exactly the kind of test that must not rot.
#
# `RUN_DOCKER_E2E=1` is what makes it honest: without it the suite skips when no
# daemon answers, which is exactly how these cases sat green-and-unrun for months.
# With it, an unreachable daemon fails in `beforeAll` instead of reporting skipped.
#
# `fast` is every daemon-level and full-cycle case (~5 min); `heavy` is
# rollback-build-restore alone (~225s cold, and fileParallelism is off, so it
# holds the whole suite up). Both run here — see E2E_SCOPE in
# apps/api/vitest.e2e.config.ts.
e2e-docker:
name: E2E (real Docker, ${{ matrix.scope }})
# Manual + release tags only. Never on main pushes or PRs.
if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }}
# Its own job rather than a turbo target: these need `bun test` (not vitest) and a
# separate install rooted in apps/email/server. Independent and parallel, so it cannot
# slow the jobs above.
webmail-server-test:
name: Test webmail server
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
scope: [fast, heavy]
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -128,22 +124,18 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
key: ${{ runner.os }}-bun-webmail-${{ hashFiles('apps/email/server/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-webmail-
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile
# Not --frozen-lockfile: the committed lockfile is regenerated by
# scripts/build-release.ts for the dist, so it can legitimately lag the manifest
# here. Resolving fresh is fine for a test-only install.
- name: Install webmail server dependencies
working-directory: apps/email/server
run: bun install

# Fail here rather than inside vitest, so "the runner lost Docker" is
# distinguishable at a glance from "a rollback assertion broke".
- name: Check the Docker daemon
run: |
docker info
docker version

- name: Run real-daemon E2E
env:
RUN_DOCKER_E2E: "1"
E2E_SCOPE: ${{ matrix.scope }}
run: bun run --cwd apps/api test:e2e
- name: Run webmail server tests
working-directory: apps/email/server
run: bun test
98 changes: 97 additions & 1 deletion .github/workflows/docker-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ permissions:
packages: write

jobs:
# Typecheck + tests. `merge-images` depends on this, so a red test cannot become a
# pullable image tag. See .github/workflows/release-gate.yml for why this is a
# called workflow rather than a trigger.
gate:
name: Release gate
uses: ./.github/workflows/release-gate.yml

# Native per-arch builds (no QEMU — reuses the ubuntu-24.04-arm runner). Each
# arch is pushed to GHCR BY DIGEST; merge-images assembles the manifest lists.
build-images:
Expand Down Expand Up @@ -112,9 +119,98 @@ jobs:
if-no-files-found: error
retention-days: 1

# The update path itself: the PREVIOUS release's stack, with rows in its database,
# recreated onto the api this run just built — `up -d --force-recreate api`, the same
# command `openship update` issues.
#
# Positioned between the build and the manifest publish on purpose. `build-images`
# pushes by digest under no tag, so at this point the new image exists but nothing
# pulls it yet; this job pulls it BY DIGEST, which makes the thing under test the exact
# bytes `merge-images` is about to tag. Testing `:latest` after publishing it would be
# testing what operators already got.
update-e2e:
name: E2E (update from previous release)
needs: build-images
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Checkout
# Tags, and all of them: the test resolves which release to upgrade FROM by
# walking `git tag`. A shallow clone has none, and it fails rather than guess.
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Cache bun install
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# This runner is amd64, so it needs the amd64 half of the build. The artifact holds
# one empty file NAMED for the digest (see "Export digest" above).
- name: Resolve the new api image digest
id: newimage
uses: actions/download-artifact@v8
with:
name: digests-api-amd64
path: /tmp/api-digest

- name: Build the image reference
id: ref
run: |
set -euo pipefail
digest="$(ls /tmp/api-digest | head -1)"
if [ -z "$digest" ]; then
echo "No digest artifact — build-images did not export one for api/amd64." >&2
exit 1
fi
echo "image=ghcr.io/${{ github.repository_owner }}/openship-api@sha256:${digest}" >> "$GITHUB_OUTPUT"

# Fail here rather than inside vitest, so "the runner lost Docker" is
# distinguishable at a glance from "the update broke".
- name: Check the Docker daemon
run: |
docker info
docker version

- name: Run the update E2E
env:
RUN_DOCKER_E2E: "1"
E2E_SCOPE: update
OPENSHIP_E2E_NEW_API_IMAGE: ${{ steps.ref.outputs.image }}
OPENSHIP_E2E_IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}
run: bun run --cwd apps/api test:e2e

merge-images:
name: Publish ${{ matrix.image }} (manifest)
needs: build-images
# `gate` as well as the builds: this job is where images become PULLABLE (the
# per-arch builds above push by digest only, under no tag), so it is the point a
# failing test has to stop. `openship update` pulls these tags — an unreachable
# gate result here is the difference between a bad release sitting unreferenced
# in the registry and every operator's stack recreating onto it.
#
# Gated on the manual path too. That path never moves `:latest`, but it does
# publish tags a box can be pointed at, and the wiring stays simpler than a
# conditional that has to be right about which publishes are "only tests".
needs: [gate, build-images, update-e2e]
runs-on: ubuntu-24.04
strategy:
fail-fast: false
Expand Down
125 changes: 125 additions & 0 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Release gate

# The tests that must pass before anything ships: the unit/integration suite, and the
# real-daemon E2E matrix. If either goes red, the release does not publish.
#
# Why this exists: `Release` and `Docker images` both trigger on the same `v*.*.*` tag
# push as `CI`, and GitHub does not order workflows against one another. So up to and
# including v0.6.5, the GitHub release, the npm CLI and the GHCR images all published
# *in parallel with* the test run and completed regardless of its result — `publish`
# needed only the build jobs, and `merge-images` only `build-images`. The e2e suite
# described itself as "the release-restorability gate" while being nothing of the kind.
# Nothing in the repo could fail an upload.
#
# `workflow_call` rather than a trigger: listing a job in `needs:` is the only way one
# workflow can block on another's result. `workflow_dispatch` is kept so the E2E matrix
# can still be run on demand, which is what CI's copy of it was for.
#
# `apps/api` IS typechecked here, because nothing else in a release does it. The build
# jobs only look like they would: `build` is `tsup --format esm` with no `--dts` and
# `build-release` is a bun compile, and both strip types without checking them. A live
# example while this was being written — `packages/db/src/dump.ts` referencing a schema
# export that no longer existed — compiled clean and would have shipped.
#
# The dashboard's typecheck deliberately stays in CI and out of this gate: it is a
# grep-filtered scan that tolerates pre-existing fumadocs errors, which is not something
# a release should hang on.

on:
workflow_call: {}
workflow_dispatch: {}

jobs:
test:
name: Typecheck + tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

# `latest`, matching what CI has always run these suites on. Deliberately NOT
# switched to the pinned .bun-version here: these two jobs now block releases,
# and changing their runtime in the same move that made them blocking is how you
# get a gate whose first red run nobody can attribute. Worth revisiting as its
# own change — a gate arguably should be reproducible.
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Cache bun install
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile

# Covers packages/* too: they resolve to source, so an error in @repo/db surfaces
# here. This is the only typecheck between a tag and a published image.
- name: Typecheck apps/api
run: bun run --cwd apps/api lint

# `turbo run test` across every package with a test script. Includes the two
# migration suites in packages/db: migrate-chain (the chain applied to a
# POPULATED database, plus a self-check proving it can fail) and
# migrations-additive (the static ADD COLUMN ... NOT NULL scan). Those are the
# coverage for "the update crash-looped on migrations".
- name: Run tests
run: bun run test

# Rollback and restore against a REAL Docker daemon. This is the only job that proves
# those paths work at all; every other test in the repo mocks the runtime. It lived in
# CI, where it ran alongside the release it claimed to gate — it is here now so a
# failure actually stops the publish.
#
# `RUN_DOCKER_E2E=1` is what makes it honest: without it the suite skips when no
# daemon answers, which is exactly how these cases sat green-and-unrun for months.
# With it, an unreachable daemon fails in `beforeAll` instead of reporting skipped.
#
# `fast` is every daemon-level and full-cycle case (~5 min); `heavy` is
# rollback-build-restore alone (~225s cold, and fileParallelism is off, so it holds
# the whole suite up). Both run here — see E2E_SCOPE in apps/api/vitest.e2e.config.ts.
e2e-docker:
name: E2E (real Docker, ${{ matrix.scope }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
scope: [fast, heavy]
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Cache bun install
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock', '**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile

# Fail here rather than inside vitest, so "the runner lost Docker" is
# distinguishable at a glance from "a rollback assertion broke".
- name: Check the Docker daemon
run: |
docker info
docker version

- name: Run real-daemon E2E
env:
RUN_DOCKER_E2E: "1"
E2E_SCOPE: ${{ matrix.scope }}
run: bun run --cwd apps/api test:e2e
Loading
Loading