test: first build
Android development APK / apk (push) Has been cancelled
Checks / go (push) Failing after 6s
Container images / publish (., deploy/pocketbase/Dockerfile, evoliohealth-pocketbase) (push) Has been cancelled
Container images / publish (., server/Dockerfile, evoliohealth-server) (push) Has been cancelled
Checks / compose (push) Has been cancelled
Checks / flutter (push) Has been cancelled

This commit is contained in:
2026-07-21 21:30:06 +02:00
parent 1f2cb70a83
commit b96edf0406
26 changed files with 714 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitea
.env
.env.*
**/.dart_tool
**/build
**/coverage
runtime
data
config
+9
View File
@@ -0,0 +1,9 @@
EVOLIOHEALTH_REGISTRY=git.zaynet.fr/tony
EVOLIOHEALTH_TAG=dev
APP_ENV=dev
APP_URL=http://localhost:8080
TZ=Europe/Paris
PUID=1000
PGID=1000
+39
View File
@@ -0,0 +1,39 @@
name: Android development APK
on:
push:
branches: [main]
paths:
- companion/**
- .gitea/workflows/android.yaml
workflow_dispatch:
permissions:
contents: read
jobs:
apk:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.44.6
channel: stable
cache: true
- name: Generate the development Android platform shell
working-directory: companion
run: flutter create --platforms=android --org fr.zaynet.evoliohealth --project-name evoliohealth_companion .
- name: Build development APK
working-directory: companion
run: flutter build apk --debug
- name: Publish development APK artifact
uses: actions/upload-artifact@v4
with:
name: evoliohealth-companion-dev
path: companion/build/app/outputs/flutter-apk/app-debug.apk
if-no-files-found: error
retention-days: 14
+67
View File
@@ -0,0 +1,67 @@
name: Checks
on:
push:
pull_request:
permissions:
contents: read
jobs:
go:
runs-on: ubuntu-latest
container: golang:1.26.5-alpine3.24
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install native test dependencies
run: apk add --no-cache build-base
- name: Format check
working-directory: server
run: test -z "$(gofmt -l .)"
- name: Vet
working-directory: server
run: go vet ./...
- name: Test
working-directory: server
run: go test -race ./...
flutter:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.44.6
channel: stable
cache: true
- name: Check Web formatting
working-directory: web
run: dart format --output=none --set-exit-if-changed .
- name: Test Web
working-directory: web
run: flutter test
- name: Analyze Web
working-directory: web
run: flutter analyze
- name: Check Companion formatting
working-directory: companion
run: dart format --output=none --set-exit-if-changed .
- name: Test Companion
working-directory: companion
run: flutter test
- name: Analyze Companion
working-directory: companion
run: flutter analyze
compose:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Validate Compose
env:
APP_URL: http://localhost:8080
run: docker compose config --quiet
+63
View File
@@ -0,0 +1,63 @@
name: Container images
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: git.zaynet.fr
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- image: evoliohealth-server
context: .
file: server/Dockerfile
- image: evoliohealth-pocketbase
context: .
file: deploy/pocketbase/Dockerfile
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Generate image metadata
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/tony/${{ matrix.image }}
tags: |
type=raw,value=dev,enable=${{ gitea.ref == 'refs/heads/main' }}
type=sha,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and publish image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
+34
View File
@@ -0,0 +1,34 @@
# Environment and secrets
.env
.env.*
!.env.example
*.key
*.pem
*.p12
*.jks
key.properties
# Go
server/bin/
server/coverage.out
# Flutter and Dart
**/.dart_tool/
**/.flutter-plugins
**/.flutter-plugins-dependencies
**/.packages
**/build/
**/coverage/
# Editors and operating systems
.idea/
.vscode/
*.iml
.DS_Store
Thumbs.db
# Runtime data
runtime/
data/
config/
+16
View File
@@ -0,0 +1,16 @@
# Changelog
All notable changes to EvolioHealth are documented in this file.
## Unreleased
### Added
- Initial Go server entry point with liveness, readiness, security headers, and graceful shutdown.
- Minimal French/English Flutter Web and Android Companion applications.
- Multi-stage server image bundling Flutter Web into the Go runtime.
- Dedicated private PocketBase image with verified release checksum.
- Hardened single-file Compose topology with no published PocketBase port.
- Gitea Actions checks, multi-architecture container publishing, and development APK workflows.
- Gitea registry defaults for `git.zaynet.fr/tony`.
+39 -1
View File
@@ -20,4 +20,42 @@ This package is the initial product and architecture source of truth for impleme
## Status
This specification defines the intended first production architecture and staged delivery plan. Future exercise and nutrition catalogs are anticipated, but their complete user workflows are not required for the first usable release.
The repository foundation is now present. It contains minimal Go, Flutter Web,
and Flutter Android entry points, container builds, a hardened Compose topology,
and Gitea Actions workflows. Business functionality remains staged according to
[the development plan](docs/development-plan.md).
## Repository layout
```text
server/ Go API and bundled Flutter Web server
web/ Flutter Web application
companion/ Flutter Android Companion
deploy/pocketbase/ Private PocketBase container
.gitea/workflows/ Checks, container publishing, and development APK
compose.yaml Single supported deployment definition
docs/ Authoritative specifications and decisions
```
## Development deployment
Copy `.env.example` to `.env`, set `APP_URL`, and start the stack with the
standard Docker Compose interface. Images default to:
```text
git.zaynet.fr/tony/evoliohealth-server:dev
git.zaynet.fr/tony/evoliohealth-pocketbase:dev
```
PocketBase has no host port and is connected only to the internal backend
network. The public application listens on host port `8080` for local
development and is the only service a reverse proxy may target.
## Automated builds
Gitea Actions requires an online runner carrying the `ubuntu-latest` label.
Repository Actions token permissions must allow `contents: read` and
`packages: write`. A push to `main` publishes multi-architecture development
images for `linux/amd64` and `linux/arm64`. The Android workflow produces a
debug APK retained as a short-lived workflow artifact; release signing is not
configured yet.
+2
View File
@@ -0,0 +1,2 @@
include: package:flutter_lints/flutter.yaml
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() {
runApp(const EvolioHealthCompanion());
}
class EvolioHealthCompanion extends StatelessWidget {
const EvolioHealthCompanion({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
supportedLocales: const [Locale('en'), Locale('fr')],
title: 'EvolioHealth Companion',
theme: ThemeData(colorSchemeSeed: const Color(0xFF006C51)),
home: const Scaffold(body: Center(child: Text('EvolioHealth Companion'))),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
name: evoliohealth_companion
description: EvolioHealth Companion for Android.
publish_to: none
version: 0.1.0+1
environment:
sdk: '>=3.12.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
+9
View File
@@ -0,0 +1,9 @@
import 'package:evoliohealth_companion/main.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('renders the product name', (tester) async {
await tester.pumpWidget(const EvolioHealthCompanion());
expect(find.text('EvolioHealth Companion'), findsOneWidget);
});
}
+73
View File
@@ -0,0 +1,73 @@
name: evoliohealth
services:
app:
image: "${EVOLIOHEALTH_REGISTRY:-git.zaynet.fr/tony}/evoliohealth-server:${EVOLIOHEALTH_TAG:-dev}"
user: "${PUID:-1000}:${PGID:-1000}"
environment:
APP_ENV: "${APP_ENV:-prod}"
APP_URL: "${APP_URL:?APP_URL must be set}"
POCKETBASE_URL: "http://pocketbase:8090"
TZ: "${TZ:-UTC}"
ports:
- "8080:8080"
volumes:
- evoliohealth_config:/config
- evoliohealth_data:/data
networks:
- frontend
- backend
depends_on:
pocketbase:
condition: service_healthy
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/health/live"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
pocketbase:
image: "${EVOLIOHEALTH_REGISTRY:-git.zaynet.fr/tony}/evoliohealth-pocketbase:${EVOLIOHEALTH_TAG:-dev}"
user: "${PUID:-1000}:${PGID:-1000}"
environment:
TZ: "${TZ:-UTC}"
expose:
- "8090"
volumes:
- evoliohealth_pocketbase:/data/pocketbase
networks:
- backend
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8090/api/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
networks:
frontend:
backend:
internal: true
volumes:
evoliohealth_config:
evoliohealth_data:
evoliohealth_pocketbase:
+23
View File
@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.7
ARG POCKETBASE_VERSION=0.38.2
ARG ALPINE_VERSION=3.24.1
FROM alpine:${ALPINE_VERSION} AS downloader
ARG POCKETBASE_VERSION
ARG TARGETARCH
RUN apk add --no-cache ca-certificates unzip wget \
&& wget -q "https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/pocketbase_${POCKETBASE_VERSION}_linux_${TARGETARCH}.zip" -O /tmp/pocketbase.zip \
&& wget -q "https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/checksums.txt" -O /tmp/checksums.txt \
&& cd /tmp \
&& grep "pocketbase_${POCKETBASE_VERSION}_linux_${TARGETARCH}.zip" checksums.txt | sha256sum -c - \
&& unzip /tmp/pocketbase.zip -d /out
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -g 1000 pocketbase \
&& adduser -D -H -u 1000 -G pocketbase pocketbase \
&& install -d -o pocketbase -g pocketbase /data/pocketbase /tmp
COPY --from=downloader /out/pocketbase /usr/local/bin/pocketbase
EXPOSE 8090
ENTRYPOINT ["/usr/local/bin/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/data/pocketbase"]
+6
View File
@@ -4,6 +4,12 @@
Ship one `compose.yaml`. Operators launch it with their normal manager; no Compose profiles or mandatory override files. `APP_ENV=dev|prod` selects application behavior.
The checked-in Compose file selects published images with
`EVOLIOHEALTH_REGISTRY` and `EVOLIOHEALTH_TAG`. These variables select immutable
deployment artifacts and are not application configuration. Development
defaults to `git.zaynet.fr/tony` and `dev`; production deployments SHOULD select
an immutable semantic-version tag.
Conceptual topology:
```yaml
+32
View File
@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1.7
ARG FLUTTER_VERSION=3.44.6
ARG GO_VERSION=1.26.5
ARG ALPINE_VERSION=3.24.1
FROM ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION} AS web-builder
WORKDIR /src/web
COPY web/pubspec.yaml web/analysis_options.yaml ./
RUN flutter pub get
COPY web/ ./
RUN flutter build web --release
FROM golang:${GO_VERSION}-alpine3.24 AS go-builder
WORKDIR /src/server
COPY server/go.mod ./
RUN go mod download
COPY server/ ./
COPY --from=web-builder /src/web/build/web ./cmd/evoliohealth/webdist
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/evoliohealth ./cmd/evoliohealth
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -g 1000 evoliohealth \
&& adduser -D -H -u 1000 -G evoliohealth evoliohealth \
&& install -d -o evoliohealth -g evoliohealth /config /data /tmp
COPY --from=go-builder /out/evoliohealth /usr/local/bin/evoliohealth
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evoliohealth"]
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"context"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
// webAssets contains the Flutter Web release copied by the container build.
//go:embed webdist
var webAssets embed.FS
type application struct {
backendAddress string
logger *slog.Logger
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
app := application{
backendAddress: envOrDefault("POCKETBASE_URL", "http://pocketbase:8090"),
logger: logger,
}
assets, err := fs.Sub(webAssets, "webdist")
if err != nil {
logger.Error("web assets are unavailable")
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", app.live)
mux.HandleFunc("GET /health/ready", app.ready)
mux.Handle("/", http.FileServer(http.FS(assets)))
server := &http.Server{
Addr: ":8080",
Handler: securityHeaders(mux),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
stop, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
go func() {
logger.Info("server started", "address", server.Addr)
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
logger.Error("server stopped unexpectedly", "error", err)
cancel()
}
}()
<-stop.Done()
shutdownContext, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownContext); err != nil {
logger.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
func (app application) live(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte(`{"status":"ok"}`))
}
func (app application) ready(response http.ResponseWriter, request *http.Request) {
address := strings.TrimPrefix(strings.TrimPrefix(app.backendAddress, "http://"), "https://")
if !strings.Contains(address, ":") {
address += ":8090"
}
connection, err := (&net.Dialer{Timeout: 2 * time.Second}).DialContext(request.Context(), "tcp", address)
if err != nil {
http.Error(response, `{"status":"unavailable"}`, http.StatusServiceUnavailable)
return
}
_ = connection.Close()
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte(`{"status":"ready"}`))
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
response.Header().Set("Referrer-Policy", "no-referrer")
response.Header().Set("X-Content-Type-Options", "nosniff")
response.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(response, request)
})
}
func envOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func (app application) String() string {
return fmt.Sprintf("application(%s)", app.backendAddress)
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestLiveDoesNotDiscloseInternals(t *testing.T) {
app := application{}
request := httptest.NewRequest(http.MethodGet, "/health/live", nil)
response := httptest.NewRecorder()
app.live(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, response.Code)
}
if response.Body.String() != `{"status":"ok"}` {
t.Fatalf("unexpected response: %s", response.Body.String())
}
}
func TestSecurityHeaders(t *testing.T) {
handler := securityHeaders(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
for _, header := range []string{"Content-Security-Policy", "Referrer-Policy", "X-Content-Type-Options", "X-Frame-Options"} {
if response.Header().Get(header) == "" {
t.Errorf("expected %s header", header)
}
}
}
@@ -0,0 +1 @@
<!doctype html><html lang="en"><body>EvolioHealth build placeholder</body></html>
+4
View File
@@ -0,0 +1,4 @@
module git.zaynet.fr/tony/EvolioHealth/server
go 1.26.0
+2
View File
@@ -0,0 +1,2 @@
include: package:flutter_lints/flutter.yaml
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
void main() {
runApp(const EvolioHealthWeb());
}
class EvolioHealthWeb extends StatelessWidget {
const EvolioHealthWeb({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
supportedLocales: const [Locale('en'), Locale('fr')],
title: 'EvolioHealth',
theme: ThemeData(colorSchemeSeed: const Color(0xFF006C51)),
home: const Scaffold(body: Center(child: Text('EvolioHealth Server'))),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
name: evoliohealth_web
description: EvolioHealth Web application.
publish_to: none
version: 0.1.0+1
environment:
sdk: '>=3.12.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
+9
View File
@@ -0,0 +1,9 @@
import 'package:evoliohealth_web/main.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('renders the product name', (tester) async {
await tester.pumpWidget(const EvolioHealthWeb());
expect(find.text('EvolioHealth Server'), findsOneWidget);
});
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Private, self-hosted health tracking.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EvolioHealth</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
{
"name": "EvolioHealth",
"short_name": "EvolioHealth",
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#006c51",
"description": "Private, self-hosted health tracking.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": []
}