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
36 changes: 31 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
# docker — builds both container images and smoke-tests them
#
# Coverage is collected on the integration job because it exercises the most
# source surface.
# source surface. That job also uploads the resulting lcov report to Codecov
# (flag `backend`) using the CODECOV_TOKEN repository secret; the upload is
# non-blocking, so a Codecov outage never fails an otherwise-green build.
#
# Security posture:
# - Least-privilege GITHUB_TOKEN (contents: read) for every job.
Expand All @@ -21,10 +23,16 @@ on:
push:
branches: [main]
pull_request:
# Dispatchable so release-prepare.yml can run the pipeline against a release
# branch. A pull request opened with GITHUB_TOKEN emits no `pull_request`
# event, so the release pull request would otherwise carry no checks at all —
# including the checks branch protection requires.
# Dispatchable for manual runs only — to re-report checks after a cancelled
# run, for example. The release flow does not use it: a release pull request is
# validated by its own `pull_request` run, which starts when it opens and
# re-runs on every push to `release/vX.Y.Z`.
#
# Because that pull request is authored by `github-actions[bot]`, GitHub holds
# its runs at `action_required` until a maintainer approves them. That was
# observed on both the CI and Security runs of the v1.0.1 release pull request,
# which were approved about a minute after they were queued. Approving them is
# part of the review, not an extra gate.
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -125,6 +133,24 @@ jobs:
if-no-files-found: error
retention-days: 7

- name: Upload coverage to Codecov
# Codecov is reporting, not a gate: `fail_ci_if_error: false` keeps an
# upload rejection or a Codecov outage from failing a green build. The
# step still runs when the test step failed (!cancelled()) so the
# partial report is not lost, and it is a no-op on fork pull requests,
# where repository secrets (CODECOV_TOKEN) are not exposed.
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
# Emitted by the `lcov` reporter in the Vitest coverage blocks. The
# backend `test:coverage` script runs Vitest from the workspace root,
# so the report lands in the root `coverage/` directory.
files: coverage/lcov.info
flags: backend
name: backend
fail_ci_if_error: false

e2e:
name: E2E smoke (real server)
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release-finalize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
# to still point at the merge commit and the publish pipeline is driven again,
# so a failed image build (or any partial run) is fixed by re-running this job.
#
# Tagging is deliberately *not* gated on the `push` CI run that the merge commit
# starts. A clean merge changes no content, so the tagged tree is the one the
# release pull request already validated: its own `pull_request` run covers every
# commit pushed to `release/vX.Y.Z` before the merge. Waiting would add minutes to
# every release to re-test the same tree. Revisit this if unreviewed commits start
# landing directly on `main`, because the merge commit would then stop being a
# reviewed tree.
#
# `pull_request` is used deliberately rather than `pull_request_target`: release
# pull requests always come from a branch in this repository, so the job gets a
# write token without ever executing anything from a fork.
Expand Down Expand Up @@ -84,6 +92,8 @@ jobs:
# The branch name is the only thing that told us the version, so
# confirm the merge actually carried the matching bump before tagging
# anything: a mis-named branch must not produce a mislabelled release.
# Neither check below waits on the merge commit's own CI run; that is
# a deliberate trade-off, documented in the header.
node --input-type=module -e '
import { readFileSync } from "node:fs";
const expected = process.env.EXPECTED_VERSION;
Expand Down
28 changes: 9 additions & 19 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
# carrying both, so maintainers review and edit the changelog through the
# normal GitHub review flow.
#
# The pull request is validated by its own CI run; nothing is dispatched from
# here. GitHub holds that run at `action_required` until a maintainer approves
# it, because the pull request is authored by the bot, so approving the held CI
# and Security runs is part of the review.
#
# Nothing is tagged or published here: merging the pull request is the
# approval, and release-finalize.yml performs the release.
#
Expand Down Expand Up @@ -56,7 +61,6 @@ jobs:
permissions:
contents: write # push the release branch
pull-requests: write # open / refresh the release pull request
actions: write # dispatch CI against the release branch (see below)
steps:
- name: Validate the requested version
id: version
Expand Down Expand Up @@ -453,7 +457,8 @@ jobs:
- [ ] The changelog entry is accurate and readable. Edit \`CHANGELOG.md\` directly on this
branch before merging.
- [ ] The version follows SemVer for the changes included.
- [ ] Required status checks are green.
- [ ] Required status checks are green — approve the held CI and Security runs so they
can report.

### What happens on merge

Expand Down Expand Up @@ -575,22 +580,6 @@ jobs:
echo "url=${url}" >> "$GITHUB_OUTPUT"
echo "Pull request: ${url}"

- name: Start CI on the release branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
set -euo pipefail
# A pull request opened with GITHUB_TOKEN does not emit a
# `pull_request` event — the same recursion guard that makes
# release-finalize.yml dispatch the Release workflow explicitly — so
# ci.yml would never run on this pull request and its status checks
# would stay "Expected". Dispatch the pipeline against the branch
# instead: the check runs land on the branch head, which is the commit
# branch protection evaluates.
gh workflow run ci.yml --ref "${BRANCH}"
echo "::notice::Dispatched CI for ${BRANCH}; watch it with 'gh run list --branch ${BRANCH}'."

- name: Summarize
if: always()
env:
Expand All @@ -603,7 +592,8 @@ jobs:
echo
echo "- Changelog range: \`${PREVIOUS_TAG}..${{ inputs.base }}\`"
echo "- Pull request: ${PR_URL:-not created}"
echo "- CI: dispatched against \`release/${TAG}\`; the pull request's checks report there."
echo "- CI: runs on the release pull request. It is held for approval (the pull request"
echo " is bot-authored); approve it so the checks report."
echo
echo "Merging the pull request runs **Release: finalize**, which tags \`${TAG}\` and"
echo "dispatches the Release workflow (GitHub Release + container images)."
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ temp/
AGENTS.md
CODEBUDDY.md
openspec/
.agents
.claude
.windsurf
skills-lock.json

# ---- Backups and database dumps --------------------------------------------
backups/
Expand Down
43 changes: 43 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,49 @@ with a regression test.
Reviewers aim to respond within a few business days. Please keep pull requests focused — one logical
change per pull request is easier to review and land.

## Release process

Releases are cut by two workflows that hand off to a third, so a release is a reviewable pull request
rather than a command run on someone's laptop:

1. **Prepare** — a maintainer dispatches
[`Release: prepare`](.github/workflows/release-prepare.yml) with the next SemVer version (for
example `1.1.0`). It refuses a version whose tag already exists, rewrites `version` in
`package.json`, `packages/backend/package.json` and `packages/frontend/package.json`, drafts a
Keep a Changelog entry from the Conventional Commits merged since the previous release tag, pushes
`release/vX.Y.Z`, and opens a `chore(release): vX.Y.Z` pull request. Notes already staged under
`## [Unreleased]` in `CHANGELOG.md` are drained into the new entry, so hand-written prose survives.
2. **Review and merge** — edit the changelog on the pull request branch, then merge. **Merging is the
approval**: nothing is tagged or published before that point.
3. **Finalize and publish** — the merged pull request triggers
[`Release: finalize`](.github/workflows/release-finalize.yml), which verifies that the merged
tree really carries the requested version and a matching `## [X.Y.Z]` changelog section, tags the
merge commit with an annotated `vX.Y.Z`, and drives the
[`Release`](.github/workflows/release.yml) workflow. That publishes the GitHub Release with notes
extracted from `CHANGELOG.md` and pushes the `custotal-backend`,
`custotal-backend:<tag>-tools` and `custotal-frontend` images to GHCR.

A release branch is validated by its own CI run, which starts when the pull request opens and re-runs
on every commit pushed to `release/vX.Y.Z`. Because that pull request is authored by a bot, GitHub
holds its CI and Security runs until a maintainer approves them, so approve each held run as part of
the review. Tagging does not wait for the merge commit's own CI run either: a clean merge changes no
content, so the tagged tree is the one the pull request already validated.

Changes to the release tooling (`.github/workflows/`) go through a pull request like any other change.
Pushing them straight to `main` skips review and the `dependency-review` job, which only runs on pull
requests.

Recovering from a partial or failed release:

- **A failed publish**, such as a failed image build: re-run `Release: finalize`. It is idempotent — an
existing tag is verified against the merge commit instead of being recreated — and re-drives the
publish pipeline.
- **A bad draft, or a version that was never merged**: dispatch `Release: prepare` again with the same
version to rebuild the branch and refresh the pull request. That rebuilds the branch from `base`, so
edit only through the pull request.
- **Republishing an existing tag**: dispatch the `Release` workflow against the tag — select the tag in
the "Use workflow from" dropdown, or run `gh workflow run release.yml --ref vX.Y.Z -f tag=vX.Y.Z`.

## Reporting security issues

Do not open a public issue. Follow [SECURITY.md](SECURITY.md).
50 changes: 43 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
# Custotal

[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![CI](https://github.com/orbivort/custotal/actions/workflows/ci.yml/badge.svg)](https://github.com/orbivort/custotal/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/orbivort/custotal?sort=semver&display_name=tag&style=flat-square)](https://github.com/orbivort/custotal/releases/latest)
[![CI](https://github.com/orbivort/custotal/actions/workflows/ci.yml/badge.svg)](https://github.com/orbivort/custotal/actions/workflows/ci.yml)
[![codecov](https://codecov.io/github/orbivort/custotal/graph/badge.svg)](https://codecov.io/github/orbivort/custotal)

[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white&style=flat-square)](./tsconfig.base.json)
[![Node.js](https://img.shields.io/badge/Node.js-%5E24-339933?logo=nodedotjs&logoColor=white&style=flat-square)](#requirements)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18+-336791.svg)](https://www.postgresql.org/)

**Your customer data, in your custody.**

**[Live demo →](https://orbivort.github.io/custotal)** — the full interface on
seeded demo data, no install and no backend required.

Custotal is a self-hosted CRM that a small sales team can actually run: one API
process, one PostgreSQL database, one Compose file — and no third party holding
your customer list.
Expand All @@ -33,6 +37,7 @@ access control.

- [Why Custotal](#why-custotal)
- [Features](#features)
- [Live demo](#live-demo)
- [Architecture at a glance](#architecture-at-a-glance)
- [Tech stack](#tech-stack)
- [Requirements](#requirements)
Expand Down Expand Up @@ -118,6 +123,33 @@ read end to end, run on one box, and walk away from without asking permission.
purge scripts, and a documented restore runbook, so an operator can run this
without paging a maintainer.

## Live demo

**[orbivort.github.io/custotal](https://orbivort.github.io/custotal)** — the full
interface on a seeded mock workspace, with no clone, database, or sign-up. The
sign-in form arrives prefilled with the demo administrator, so the whole app is
one click away. The seeded accounts below share the password `demo1234` and show
how the same workspace is scoped by role:

| Account | Role | What it shows |
| ------------------- | --------- | --------------------------------- |
| `admin@example.com` | Admin | Everything, including admin pages |
| `dana@example.com` | Manager | Team-wide visibility |
| `alex@example.com` | Rep | Only their own book |
| `riley@example.com` | Read-only | No create or edit actions |

Two things to know, so the demo is not mistaken for a deployment:

- **There is no backend.** Mock Service Worker answers every `/api` call in the
browser and keeps the dataset in `localStorage`: nothing leaves the tab, and
your changes stay local until you clear site data.
- **It is generated from this source rather than maintained by hand.**
`pnpm build:demo` produces the bundle (see [Commands](#commands)), and
[`.github/workflows/pages.yml`](.github/workflows/pages.yml) asserts the mock
dataset is present before publishing. The opposite check — that `pnpm build`
leaves the mock graph out of a production bundle — is documented in
[Frontend API integration](packages/frontend/docs/api-integration.md).

## Architecture at a glance

```mermaid
Expand Down Expand Up @@ -231,10 +263,12 @@ To try the UI **without a database**, set `VITE_ENABLE_MOCKS=true` in
seeded demo workspace in development only. It is never active outside the Vite
dev server, and the flag must not be `true` in a production build.

The same demo is available as a standalone static site: `pnpm build:demo`
produces the bundle published to GitHub Pages by
[`.github/workflows/pages.yml`](.github/workflows/pages.yml). That build boots the
mock workspace on purpose — the site it produces has no backend behind it.
The same demo is already published standalone at
<https://orbivort.github.io/custotal> — see [Live demo](#live-demo). To build it
locally instead, `pnpm build:demo` produces the bundle that
[`.github/workflows/pages.yml`](.github/workflows/pages.yml) deploys; that build
boots the mock workspace on purpose, because the site it produces has no backend
behind it.

For a production deployment, see [`docs/self-hosting.md`](docs/self-hosting.md).

Expand Down Expand Up @@ -391,6 +425,7 @@ allowed to import the HTTP client or write `/api/...` paths.

## Documentation

- [User guide](docs/user-guide.md) — how to use Custotal day to day, for business users.
- [Architecture](docs/architecture.md) — how the backend and frontend fit together.
- [Self-hosting](docs/self-hosting.md) — configuration, deployment, backups, and upgrades.
- [API reference](docs/api.md) — REST endpoints and the error envelope.
Expand Down Expand Up @@ -456,8 +491,9 @@ timer, so a second replica multiplies the effective limits and races the purge.
See [operations notes §1](packages/backend/docs/operations-notes.md).

**Can I evaluate the UI without installing PostgreSQL?**
Yes — set `VITE_ENABLE_MOCKS=true` and run `pnpm dev`. You still need a clone and
`pnpm install`, but no database.
Yes. The fastest path is the [live demo](https://orbivort.github.io/custotal),
which needs no clone at all. To run it locally, set `VITE_ENABLE_MOCKS=true` and
run `pnpm dev`: you still need a clone and `pnpm install`, but no database.

**How do I restore from a backup?**
Follow the [restore procedure](packages/backend/docs/restore-procedure.md). The
Expand Down
Loading
Loading