Skip to content

Commit ccc3e2c

Browse files
Add GH Actions build workflow for backend (#77)
1 parent ee64013 commit ccc3e2c

File tree

11 files changed

+352
-60
lines changed

11 files changed

+352
-60
lines changed

.github/workflows/build.yml

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
workflow_dispatch: {}
9+
10+
jobs:
11+
backend:
12+
name: Backend checks
13+
runs-on: ubuntu-latest
14+
15+
services:
16+
postgres:
17+
image: postgres:16
18+
ports:
19+
- 5432:5432
20+
env:
21+
POSTGRES_USER: simboard
22+
POSTGRES_PASSWORD: simboard
23+
POSTGRES_DB: simboard
24+
options: >-
25+
--health-cmd="pg_isready -U simboard -d simboard"
26+
--health-interval=5s
27+
--health-timeout=5s
28+
--health-retries=5
29+
30+
env:
31+
# --------------------------------------------------
32+
# Execution context
33+
# --------------------------------------------------
34+
CI: "true"
35+
APP_ENV: test
36+
37+
# --------------------------------------------------
38+
# General
39+
# --------------------------------------------------
40+
ENV: test
41+
ENVIRONMENT: test
42+
DOMAIN: localhost
43+
STACK_NAME: simboard
44+
PORT: 8000
45+
46+
# --------------------------------------------------
47+
# Frontend (required by backend config)
48+
# --------------------------------------------------
49+
FRONTEND_ORIGIN: https://127.0.0.1:5173
50+
FRONTEND_AUTH_REDIRECT_URL: https://127.0.0.1:5173/auth/callback
51+
52+
# --------------------------------------------------
53+
# Database
54+
# --------------------------------------------------
55+
DATABASE_URL: postgresql+psycopg://simboard:simboard@localhost:5432/simboard
56+
TEST_DATABASE_URL: postgresql+psycopg://simboard:simboard@localhost:5432/simboard_test
57+
58+
# --------------------------------------------------
59+
# GitHub OAuth (dummy values for CI)
60+
# --------------------------------------------------
61+
GITHUB_CLIENT_ID: dummy
62+
GITHUB_CLIENT_SECRET: dummy
63+
GITHUB_REDIRECT_URL: http://localhost/api/auth/github/callback
64+
GITHUB_STATE_SECRET_KEY: dummy
65+
66+
# --------------------------------------------------
67+
# Cookie config
68+
# --------------------------------------------------
69+
COOKIE_NAME: simboard_auth
70+
COOKIE_SECURE: "false"
71+
COOKIE_HTTPONLY: "true"
72+
COOKIE_SAMESITE: lax
73+
COOKIE_MAX_AGE: "3600"
74+
75+
steps:
76+
# -------------------------
77+
# Skip duplicate runs
78+
# -------------------------
79+
- name: Skip duplicate runs
80+
uses: fkirc/skip-duplicate-actions@v5
81+
with:
82+
github_token: ${{ github.token }}
83+
skip_after_successful_duplicate: true
84+
cancel_others: true
85+
86+
# -------------------------
87+
# Checkout
88+
# -------------------------
89+
- name: Checkout repository
90+
uses: actions/checkout@v4
91+
92+
# -------------------------
93+
# Python
94+
# -------------------------
95+
- name: Set up Python
96+
uses: actions/setup-python@v5
97+
with:
98+
python-version: "3.13"
99+
100+
# -------------------------
101+
# Install uv
102+
# -------------------------
103+
- name: Install uv
104+
run: |
105+
curl -LsSf https://astral.sh/uv/install.sh | sh
106+
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
107+
108+
# -------------------------
109+
# Cache uv + dependencies
110+
# -------------------------
111+
- name: Cache uv
112+
uses: actions/cache@v4
113+
with:
114+
path: |
115+
~/.cache/uv
116+
backend/.venv
117+
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
118+
restore-keys: |
119+
uv-${{ runner.os }}-
120+
121+
# -------------------------
122+
# Install backend deps
123+
# -------------------------
124+
- name: Install backend dependencies
125+
working-directory: backend
126+
run: |
127+
uv venv .venv
128+
uv sync --all-groups
129+
130+
# -------------------------
131+
# Cache pre-commit
132+
# -------------------------
133+
- name: Cache pre-commit
134+
uses: actions/cache@v4
135+
with:
136+
path: ~/.cache/pre-commit
137+
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
138+
restore-keys: |
139+
pre-commit-${{ runner.os }}-
140+
141+
# -------------------------
142+
# Run pre-commit
143+
# -------------------------
144+
- name: Run pre-commit
145+
working-directory: backend
146+
run: |
147+
uv run pre-commit run --all-files
148+
149+
# -------------------------
150+
# Run pytest
151+
# -------------------------
152+
- name: Run pytest
153+
working-directory: backend
154+
run: |
155+
uv run pytest

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ repos:
2222
- id: ruff-format
2323
files: ^backend/
2424

25+
- repo: https://github.com/pre-commit/mirrors-mypy
26+
rev: v1.18.2
27+
hooks:
28+
- id: mypy
29+
name: mypy (type checking)
30+
files: ^backend/.*\.py$
31+
additional_dependencies:
32+
- fastapi
33+
- pydantic
34+
- sqlalchemy
35+
2536
# ------------------------
2637
# Frontend (Node / TS)
2738
# NOTE: Git hooks run in a non-interactive shell. Node tooling (pnpm) must be available on PATH.

backend/app/core/config.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,47 @@
55
from pydantic_settings import BaseSettings, SettingsConfigDict
66

77

8-
def get_env_file(project_root: Path | None = None) -> str:
8+
def get_env_file(project_root: Path | None = None) -> str | None:
9+
"""Determine which environment-specific .env file to load.
10+
11+
Behavior:
12+
- In CI (CI=true), rely solely on environment variables.
13+
- Otherwise, require `.envs/<APP_ENV>/backend.env`.
14+
15+
This avoids brittle heuristics based on partial env var presence.
16+
17+
Parameters
18+
----------
19+
project_root : Path or None, optional
20+
The root directory of the project. If None, it is inferred from the file
21+
location.
22+
23+
Returns
24+
-------
25+
str or None
26+
The path to the environment file as a string, or None if running in CI.
27+
28+
Raises
29+
------
30+
FileNotFoundError
31+
If the required environment file does not exist.
932
"""
10-
Determine which environment-specific .env file to load.
33+
# In CI, do not require an env file
34+
if os.getenv("CI", "").lower() == "true":
35+
return None
1136

12-
Uses APP_ENV to select one of:
13-
* .envs/dev/backend.env
14-
* .envs/dev_docker/backend.env
15-
* .envs/prod/backend.env
16-
17-
Defaults to `.envs/dev/backend.env` when APP_ENV is not set.
18-
Ignores any .example files.
19-
"""
2037
app_env = os.getenv("APP_ENV", "dev")
2138

2239
if project_root is None:
2340
project_root = Path(__file__).resolve().parents[3]
2441

2542
env_file = project_root / ".envs" / app_env / "backend.env"
2643

27-
if env_file.name.endswith(".example"):
28-
raise FileNotFoundError("Refusing to load .example env files.")
29-
3044
if not env_file.exists():
31-
raise FileNotFoundError(f"Environment file '{env_file}' does not exist.")
45+
raise FileNotFoundError(
46+
f"Environment file '{env_file}' does not exist. "
47+
"Create it or set CI=true to rely on environment variables."
48+
)
3249

3350
return str(env_file)
3451

backend/app/scripts/seed.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def create_dev_oauth_user(db: Session):
9898
print(f"✅ Created dummy user: {user.email}")
9999

100100
# 3. Create the linked OAuthAccount
101-
oauth: OAuthAccount = OAuthAccount(
101+
oauth = OAuthAccount(
102102
user_id=user.id,
103103
oauth_name=provider,
104104
account_id="123456", # fake GitHub user ID

backend/tests/core/test_config.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
from app.core.config import get_env_file
66

77

8+
@pytest.fixture(autouse=True)
9+
def disable_ci(monkeypatch):
10+
"""
11+
Ensure tests exercise *local development* behavior.
12+
13+
In CI, we intentionally set `CI=true`, which causes `get_env_file()`
14+
to return None and rely solely on environment variables.
15+
16+
This test suite validates the *file-based* behavior used in local
17+
development (i.e., resolving `.envs/<APP_ENV>/backend.env`), so we
18+
explicitly unset `CI` here to avoid CI-specific code paths.
19+
"""
20+
monkeypatch.delenv("CI", raising=False)
21+
22+
823
class TestGetEnvFile:
924
@pytest.fixture(autouse=True)
1025
def restore_env(self, monkeypatch):
@@ -37,7 +52,7 @@ def test_returns_dev_env_file_by_default(self, tmp_path, monkeypatch):
3752
(root / ".envs/dev/backend.env").write_text("OK")
3853
env_file = get_env_file(project_root=root)
3954

40-
assert env_file.endswith("dev/backend.env")
55+
assert env_file.endswith("dev/backend.env") # type: ignore[union-attr]
4156

4257
def test_returns_dev_env_file_when_app_env_is_dev(self, tmp_path, monkeypatch):
4358
monkeypatch.setenv("APP_ENV", "dev")
@@ -46,7 +61,7 @@ def test_returns_dev_env_file_when_app_env_is_dev(self, tmp_path, monkeypatch):
4661
(root / ".envs/dev/backend.env").write_text("OK")
4762
env_file = get_env_file(project_root=root)
4863

49-
assert env_file.endswith("dev/backend.env")
64+
assert env_file.endswith("dev/backend.env") # type: ignore[union-attr]
5065

5166
def test_returns_dev_docker_env_file_when_app_env_is_dev_docker(
5267
self, tmp_path, monkeypatch
@@ -55,9 +70,9 @@ def test_returns_dev_docker_env_file_when_app_env_is_dev_docker(
5570
root = tmp_path
5671
(root / ".envs/dev_docker").mkdir(parents=True)
5772
(root / ".envs/dev_docker/backend.env").write_text("OK")
58-
env_file = get_env_file(project_root=root)
5973

60-
assert env_file.endswith("dev_docker/backend.env")
74+
env_file = get_env_file(project_root=root)
75+
assert env_file.endswith("dev_docker/backend.env") # type: ignore[union-attr]
6176

6277
def test_returns_prod_env_file_when_app_env_is_prod(self, tmp_path, monkeypatch):
6378
monkeypatch.setenv("APP_ENV", "prod")
@@ -66,7 +81,7 @@ def test_returns_prod_env_file_when_app_env_is_prod(self, tmp_path, monkeypatch)
6681
(root / ".envs/prod/backend.env").write_text("OK")
6782

6883
env_file = get_env_file(project_root=root)
69-
assert env_file.endswith("prod/backend.env")
84+
assert env_file.endswith("prod/backend.env") # type: ignore[union-attr]
7085

7186
def test_raises_when_only_example_env_file_exists(self, tmp_path, monkeypatch):
7287
monkeypatch.setenv("APP_ENV", "dev")
@@ -84,16 +99,9 @@ def test_raises_when_env_file_does_not_exist(self, tmp_path, monkeypatch):
8499
with pytest.raises(FileNotFoundError):
85100
get_env_file(project_root=root)
86101

87-
def test_raises_when_env_file_is_missing_and_only_example_exists(
88-
self, tmp_path, monkeypatch
89-
):
90-
monkeypatch.setenv("APP_ENV", "dev")
102+
def test_returns_none_if_environment_is_ci(self, tmp_path, monkeypatch):
103+
monkeypatch.setenv("CI", "true")
91104
root = tmp_path
92-
(root / ".envs/dev").mkdir(parents=True)
93-
(root / ".envs/dev/backend.env.example").write_text("# example")
94-
env_file = root / ".envs/dev/backend.env"
105+
env_file = get_env_file(project_root=root)
95106

96-
if env_file.exists():
97-
env_file.unlink()
98-
with pytest.raises(FileNotFoundError):
99-
get_env_file(project_root=root)
107+
assert env_file is None

backend/tests/features/machine/test_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def test_endpoint_succeeds_with_valid_payload(self, client):
3535
"notes": "Another test machine",
3636
}
3737

38-
res = client.post("/machines", json=payload)
38+
res = client.post("/api/machines", json=payload)
3939

4040
assert res.status_code == 201
4141
data = res.json()
@@ -93,7 +93,7 @@ def test_endpoint_raises_400_for_duplicate_name(self, client, db: Session):
9393
"notes": "Duplicate machine",
9494
}
9595

96-
res = client.post("/machines", json=payload)
96+
res = client.post("/api/machines", json=payload)
9797
assert res.status_code == 400
9898
assert res.json()["detail"] == "Machine with this name already exists"
9999

@@ -128,7 +128,7 @@ def test_endpoint_successfully_list_machines(self, client):
128128
"chrysalis",
129129
}
130130

131-
res = client.get("/machines")
131+
res = client.get("/api/machines")
132132
assert res.status_code == 200
133133
data = res.json()
134134

@@ -167,7 +167,7 @@ def test_endpoint_successfully_get_machine(self, client, db: Session):
167167
db.commit()
168168
db.refresh(expected)
169169

170-
res = client.get(f"/machines/{expected.id}")
170+
res = client.get(f"/api/machines/{expected.id}")
171171
assert res.status_code == 200
172172

173173
result_endpoint = res.json()
@@ -185,6 +185,6 @@ def test_function_raises_error_if_machine_not_found(self, db: Session):
185185
def test_endpoint_raises_404_if_machine_not_found(self, client):
186186
random_id = uuid4()
187187

188-
res = client.get(f"/machines/{random_id}")
188+
res = client.get(f"/api/machines/{random_id}")
189189
assert res.status_code == 404
190190
assert res.json()["detail"] == "Machine not found"

0 commit comments

Comments
 (0)