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
58 changes: 58 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Contributing to Compendio

Thanks for helping improve Compendio. This page covers how the repository is organised and what a
change has to clear before it can merge.

## Branching model

| Branch | Purpose | Direct pushes |
|---|---|---|
| `master` | Released code. Tagged releases are cut from here. | Protected — PR only |
| `develop` | Integration branch. **Open your pull requests against this one.** | Protected — PR only |
| `feature/*`, `fix/*` | Your work, branched from `develop`. | Free |

Release flow: `feature/*` → `develop` → `master`.

## Rules for merging

Both `develop` and `master` are protected by a repository ruleset. To merge you need:

- a **pull request** — direct pushes are blocked;
- at least **one approving review**;
- **all CI checks green** (`client`, `server`, `contract`, `licence`);
- every **review conversation resolved**;
- no force-pushes and no branch deletion.

The **repository owner is on the bypass list** and can merge without satisfying these — that is for
hotfixes and administrative changes, and is meant to be used sparingly.

## Reporting bugs

Open an issue with the **Bug report** template. A good report includes what you expected, what
happened instead, the **version** (`GET /api/v1/about` or the footer of any page), how the instance
is running (Windows Service, systemd, Docker, or standalone), and any logs — **with secrets and
encrypted-folder contents removed**.

Found a security problem? Report it privately through GitHub's **Report a vulnerability** button on
the Security tab rather than opening a public issue.

## Making a change

1. Branch from `develop`.
2. Build and test:
```bash
dotnet build
dotnet test
```
For client work, also run the checks in `src/client`: `npm run check:i18n`, `npx tsc -b`, and
`npx vitest run`.
3. Keep every user-facing string in **both Spanish and English**.
4. Update documentation when behaviour or the API changes, and regenerate the API contract if you
touched an endpoint (see [`docs/development.md`](../docs/development.md)).
5. Open a pull request against `develop` and fill in the template.

## Licence

Compendio is **AGPL-3.0-or-later**. By contributing you agree your changes are licensed under it,
and every dependency must be licence-compatible — CI rejects one that is not (see
[`.github/scripts/check-licences.py`](scripts/check-licences.py)).
53 changes: 53 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Bug report
description: Something in Compendio behaves incorrectly
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. Please fill in as much as you can.
Do **not** paste secrets, passwords, or encrypted-folder contents.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you do, what did you expect, and what happened instead?
placeholder: Steps to reproduce, expected vs. actual behaviour.
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: From `GET /api/v1/about` or the footer of any page.
placeholder: e.g. 1.1.0
validations:
required: true
- type: dropdown
id: platform
attributes:
label: How is it running?
options:
- Windows Service
- systemd (Linux)
- Docker
- Standalone executable / development
validations:
required: true
- type: dropdown
id: language
attributes:
label: Interface language
options:
- Español
- English
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Paste any relevant log output, with secrets removed.
render: shell
validations:
required: false
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
blank_issues_enabled: false
29 changes: 29 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Feature request
description: Suggest an improvement, keeping Compendio's scope in mind
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Compendio deliberately refuses some things — no SMTP, no real-time co-editing, no plugin
marketplace, no native mobile apps, and no required external service. Please read the
README's **"What it refuses to be"** section before filing.
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: Describe the need, not just the feature.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
validations:
required: false
12 changes: 12 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## What and why

<!-- What does this change do, and why? Link any related issue, e.g. Fixes #123 -->

## Checklist

- [ ] Targets the `develop` branch (not `master`)
- [ ] `dotnet build` and `dotnet test` pass locally
- [ ] Client checks pass (`npm run check:i18n`, `npx tsc -b`, `npx vitest run`) if the client changed
- [ ] User-facing text is provided in **both Spanish and English**
- [ ] Docs updated if behaviour or the API changed (`docs/`, in-product Help, `docs/openapi/v1.json`)
- [ ] No secrets, credentials, or encrypted-folder contents committed
33 changes: 33 additions & 0 deletions .github/rulesets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Branch protection ruleset

[`protected-branches.json`](protected-branches.json) is the repository ruleset that protects
`master` and `develop`. It is stored here as code so the rules are reviewable and reproducible.

## What it enforces

On both `master` and `develop`:

- **Pull request required** — no direct pushes, with **1 approving review** and all review
conversations resolved.
- **Status checks must pass** — `client`, `server (ubuntu-latest)`, `server (windows-latest)`,
`contract`, and `licence`.
- **No force-pushes** (`non_fast_forward`) and **no branch deletion**.

The **repository owner** (admin role) is on the bypass list and can merge without satisfying these.

## Applying it

Repository rulesets require the repository to be **public** (or on a paid plan). Once the repo is
public, apply — or update — the ruleset with:

```bash
# Create it the first time
gh api -X POST repos/shernandezp/Compendio/rulesets \
-H "Accept: application/vnd.github+json" \
--input .github/rulesets/protected-branches.json

# Update it later (RULESET_ID from: gh api repos/shernandezp/Compendio/rulesets)
gh api -X PUT repos/shernandezp/Compendio/rulesets/RULESET_ID \
-H "Accept: application/vnd.github+json" \
--input .github/rulesets/protected-branches.json
```
42 changes: 42 additions & 0 deletions .github/rulesets/protected-branches.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "Protected branches",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }
],
"conditions": {
"ref_name": {
"include": ["refs/heads/master", "refs/heads/develop"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": true,
"allowed_merge_methods": ["merge", "squash", "rebase"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "client" },
{ "context": "server (ubuntu-latest)" },
{ "context": "server (windows-latest)" },
{ "context": "contract" },
{ "context": "licence" }
]
}
}
]
}
38 changes: 34 additions & 4 deletions .github/scripts/check-licences.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
of known-bad ones.
"""

import gzip
import json
import sys
import urllib.error
Expand Down Expand Up @@ -39,18 +40,47 @@
"Common.Mediator",
)

# Exact package ids that are known-good but carry no SPDX expression the check can read.
# SQLite — the native e_sqlite3 build (author Eric Sink, projectUrl sqlite.org) that the trusted
# SQLitePCLRaw.* family depends on. SQLite itself is public domain; the package only ships the
# deprecated aka.ms/deprecateLicenseUrl placeholder instead of a licence expression.
TRUSTED_IDS = {
"SQLite",
}

NUGET_REGISTRATION = "https://api.nuget.org/v3/registration5-gz-semver2/{id}/{version}.json"


def licence_for(package_id: str, version: str) -> str | None:
url = NUGET_REGISTRATION.format(id=package_id.lower(), version=version.lower())
try:
with urllib.request.urlopen(url, timeout=20) as response:
data = json.load(response)
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
body = response.read()
# The registration5-gz-semver2 resource serves gzip-encoded content that urllib does not
# transparently decode, so a raw json.load would choke on the 0x1f 0x8b magic bytes.
if body[:2] == b"\x1f\x8b":
body = gzip.decompress(body)
data = json.loads(body)
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None

catalog = data.get("catalogEntry") if isinstance(data, dict) else None

# `catalogEntry` is normally an inlined object, but the registration hive is allowed to leave it
# as a bare URL string when the entry is not inlined. Resolve that one extra hop before giving up.
if isinstance(catalog, str):
try:
with urllib.request.urlopen(catalog, timeout=20) as response:
body = response.read()
if body[:2] == b"\x1f\x8b":
body = gzip.decompress(body)
catalog = json.loads(body)
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None

if not isinstance(catalog, dict):
return None

catalog = data.get("catalogEntry", {})
return catalog.get("licenseExpression") or catalog.get("licenseUrl")


Expand All @@ -69,7 +99,7 @@ def main(path: str) -> int:
unknown: list[str] = []

for package_id, version in sorted(seen.items()):
if package_id.startswith(TRUSTED_PREFIXES):
if package_id.startswith(TRUSTED_PREFIXES) or package_id in TRUSTED_IDS:
continue

expression = licence_for(package_id, version)
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [main]
branches: [master, develop]
pull_request:
workflow_dispatch:

Expand Down Expand Up @@ -128,7 +128,7 @@ jobs:
working-directory: src/client
run: |
npm ci --no-audit --no-fund
npx license-checker-rseidelsohn --production --summary \
npx license-checker-rseidelsohn --production --summary --excludePrivatePackages \
--onlyAllow "MIT;ISC;Apache-2.0;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;Python-2.0;BlueOak-1.0.0;CC-BY-4.0"

docker:
Expand Down
20 changes: 16 additions & 4 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ RUN dotnet publish Compendio/src/Server/Compendio.Server.csproj \
-p:SkipClientBuild=true \
--no-restore

# Pre-create the data tree here; the chiselled runtime has no shell to mkdir at runtime. Ownership
# is set on the way into the runtime stage below, where the app user's UID is known.
RUN mkdir -p /data/content /data/db /data/keys /data/logs /data/backups


# ---- Runtime -----------------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
Expand All @@ -56,15 +60,23 @@ ENV DOTNET_RUNNING_IN_CONTAINER=true \
WORKDIR /app
COPY --from=server /app .

# Three volumes, not one. `keys` is separate and called out in the docs because losing it loses
# every encrypted page — an operator who bind-mounts only `content` and `db` finds that out late.
VOLUME ["/data/content", "/data/db", "/data/keys"]
# The runtime user is the image's non-root app user ($APP_UID, 1654 on the .NET 10 chiselled base).
# The data tree is copied in already owned by it, so the first write to /data/logs or the creation
# of /data/keys/dataprotection is not denied. $APP_UID is used rather than a hard-coded number
# because Microsoft has changed it between releases (it was 64198 on .NET 8).
COPY --from=server --chown=$APP_UID:$APP_UID /data /data

# No VOLUME declarations: under the containerd image store an anonymous volume is created root-owned
# rather than inheriting the ownership above, which stops the non-root process writing to /data/db
# or /data/keys. Persistence is always mounted explicitly instead — the three paths to mount
# (content, db, and keys separately) are documented in deploy/docker-compose.yml, because losing
# `keys` loses every encrypted page.

EXPOSE 8080

# The chiselled image has no shell, so the health check is the app checking itself.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["/app/compendio", "--version"]

# Non-root by default in the chiselled base (UID 64198).
# Non-root by default in the chiselled base ($APP_UID).
ENTRYPOINT ["/app/compendio"]
4 changes: 1 addition & 3 deletions src/Server.Tests/Compendio.Server.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@

<ItemGroup>
<!-- SkipClientBuild: the test run does not need the SPA, and npm in CI test jobs is a tax. -->
<ProjectReference Include="..\Server\Compendio.Server.csproj">
<SetTargetFramework>TargetFramework=net10.0</SetTargetFramework>
</ProjectReference>
<ProjectReference Include="..\Server\Compendio.Server.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading