diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d01fd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +bin/ + +web/node_modules +web/dist +web/.angular diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d67ddf4..bf1e7a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -79,3 +79,20 @@ jobs: - name: Test run: npm test + + # Builds the image on every push as a health check (catches a broken + # Dockerfile immediately); doesn't push anywhere. release.yml does the + # same build and pushes it. + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: false diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 13136d8..ab73d47 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,12 +1,21 @@ +# +# .gitea/workflows/release.yml +# +# On a version tag (e.g. v1.2.3): builds every client, pushes the API's +# Docker image, uploads all build artifacts to the Gitea generic package +# registry, and creates a Gitea release with those same files attached. +# name: Release -# A pushed tag like v1.2.3 builds every client and attaches the result to a -# Gitea release of the same name. Tag a commit that has already passed CI -# on main; this workflow doesn't re-run those checks. on: push: tags: ["v*.*.*"] +env: + PROJECT_NAME: Completed + REGISTRY: gitea.sanplex.xyz + REGISTRY_OWNER: sansan + jobs: # cmd/api and cmd/tui are pure Go (no cgo), so they cross-compile cleanly # from one Linux runner for every OS/arch below. @@ -47,7 +56,7 @@ jobs: - name: Archive run: | cd dist - base="completed_${{ matrix.goos }}_${{ matrix.goarch }}" + base="${PROJECT_NAME}_${{ matrix.goos }}_${{ matrix.goarch }}" if [ "${{ matrix.goos }}" = "windows" ]; then zip "../${base}.zip" completed-api.exe completed-tui.exe else @@ -56,18 +65,29 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: completed_${{ matrix.goos }}_${{ matrix.goarch }} - path: completed_${{ matrix.goos }}_${{ matrix.goarch }}.* + name: ${{ env.PROJECT_NAME }}_${{ matrix.goos }}_${{ matrix.goarch }} + path: ${{ env.PROJECT_NAME }}_${{ matrix.goos }}_${{ matrix.goarch }}.* retention-days: 7 - # cmd/gui (Fyne) uses cgo and links the system's native GL/windowing - # libraries, so - unlike build-go above - it can't be cross-compiled for - # other operating systems from this one Linux runner. This job only - # produces a linux/amd64 build. A macOS or Windows build of the GUI would - # need this workflow extended with a job running on a matching - # self-hosted runner (there isn't one registered for this repo today). - build-gui-linux: + # cmd/gui (Fyne) uses cgo, so unlike build-go above it can't just cross- + # compile with CGO_ENABLED=0. Linux is native to this runner; Windows + # cross-compiles cleanly with the mingw-w64 toolchain (verified locally: + # a real PE32+ GUI-subsystem .exe, linking Fyne's OpenGL/glfw backend, + # with no changes needed beyond CC and the usual GOOS/GOARCH). macOS + # isn't included - cross-compiling cgo to Darwin from Linux has no + # equivalent well-supported toolchain, so that would need an actual + # macOS runner, which isn't registered for this repo. + build-gui: runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + packages: libgl1-mesa-dev xorg-dev + - goos: windows + goarch: amd64 + packages: gcc-mingw-w64-x86-64 steps: - uses: actions/checkout@v4 @@ -78,20 +98,35 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libgl1-mesa-dev xorg-dev + sudo apt-get install -y ${{ matrix.packages }} - name: Build gui + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "1" run: | mkdir -p dist - go build -o dist/completed-gui ./cmd/gui + if [ "${{ matrix.goos }}" = "windows" ]; then + CC=x86_64-w64-mingw32-gcc go build -ldflags "-H=windowsgui" -o dist/completed-gui.exe ./cmd/gui + else + go build -o dist/completed-gui ./cmd/gui + fi - name: Archive - run: tar czf completed-gui_linux_amd64.tar.gz -C dist completed-gui + run: | + cd dist + base="../${PROJECT_NAME}-gui_${{ matrix.goos }}_${{ matrix.goarch }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + zip "${base}.zip" completed-gui.exe + else + tar czf "${base}.tar.gz" completed-gui + fi - uses: actions/upload-artifact@v4 with: - name: completed-gui_linux_amd64 - path: completed-gui_linux_amd64.tar.gz + name: ${{ env.PROJECT_NAME }}-gui_${{ matrix.goos }}_${{ matrix.goarch }} + path: ${{ env.PROJECT_NAME }}-gui_${{ matrix.goos }}_${{ matrix.goarch }}.* retention-days: 7 build-web: @@ -112,57 +147,71 @@ jobs: npm run build - name: Archive - run: tar czf completed-web_dist.tar.gz -C web/dist/web . + run: tar czf ${PROJECT_NAME}-web_dist.tar.gz -C web/dist/web/browser . - uses: actions/upload-artifact@v4 with: - name: completed-web_dist - path: completed-web_dist.tar.gz + name: ${{ env.PROJECT_NAME }}-web_dist + path: ${{ env.PROJECT_NAME }}-web_dist.tar.gz retention-days: 7 - release: - needs: [build-go, build-gui-linux, build-web] + # Builds and pushes the API's image, which embeds the web build as its + # static UI (see the Dockerfile and cmd/api/main.go's registerWebUI). + build-docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + config-inline: | + [registry."${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}"] + http = true + insecure = true + + - name: Login to Gitea + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/completed:${{ gitea.ref_name }} + ${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/completed:latest + + release: + needs: [build-go, build-gui, build-web] + runs-on: ubuntu-latest + steps: - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true - - name: Create the Gitea release and upload each artifact + - name: Upload to the Gitea generic package registry env: - # Gitea Actions auto-provides this for the compatibility GITHUB_* - # context; it needs the repo's Settings > Actions permissions set - # to allow writing contents. If your instance doesn't populate it, - # replace with a secret holding a personal/repo access token that - # has repo write access instead. - GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SERVER_URL: ${{ github.server_url }} - REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} + VERSION: ${{ gitea.ref_name }} run: | - set -euo pipefail - sudo apt-get update && sudo apt-get install -y jq - - release_json=$(curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \ - "${SERVER_URL}/api/v1/repos/${REPO}/releases") - release_id=$(echo "$release_json" | jq -r '.id') - - if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then - echo "Failed to create release: $release_json" >&2 - exit 1 - fi - for f in artifacts/*; do - name=$(basename "$f") + name="$(basename "$f")" echo "Uploading $name" - curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -F "attachment=@${f}" \ - "${SERVER_URL}/api/v1/repos/${REPO}/releases/${release_id}/assets?name=${name}" + curl --fail --user "${{ github.repository_owner }}:${{ secrets.TOKEN }}" \ + --upload-file "$f" \ + "https://${{ env.REGISTRY }}/api/packages/${{ env.REGISTRY_OWNER }}/generic/${{ env.PROJECT_NAME }}/${VERSION}/${name}" done + + - name: Create release + uses: akkuman/gitea-release-action@v1 + env: + NODE_OPTIONS: "--experimental-fetch" # if nodejs < 18 + with: + files: |- + artifacts/** diff --git a/Dockerfile b/Dockerfile index 78c9e01..3439af2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,18 @@ -# Build Stage -FROM golang:1.25-alpine AS builder +# Web build stage - the API serves this directory itself (see +# registerWebUI in cmd/api/main.go); there's no separate nginx/web image. +FROM node:22-alpine AS build_web + +WORKDIR /app/web + +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build +# Output: /app/web/dist/web/browser + +# API build stage +FROM golang:1.25-alpine AS build_go WORKDIR /app @@ -29,8 +42,15 @@ WORKDIR /app RUN apk add --no-cache ca-certificates # Copy the binary from the builder stage. Migrations are embedded in the -# binary (see db/migrations/embed.go), so there's nothing else to copy. -COPY --from=builder /app/api-server . +# binary (see db/migrations/embed.go), so there's nothing else to copy for +# the API itself. +COPY --from=build_go /app/api-server . + +# The web app's build output. registerWebUI (cmd/api/main.go) serves this +# directory as a single-page app, with unknown paths falling back to +# index.html for Angular's router; it no-ops if WEB_DIST_DIR is missing, so +# this layer is the only thing that turns that serving on. +COPY --from=build_web /app/web/dist/web/browser /frontend # Expose the application port EXPOSE 8080 @@ -38,6 +58,7 @@ EXPOSE 8080 # Environment variables with defaults (can be overridden) ENV PORT=8080 ENV DATABASE_URL="" +ENV WEB_DIST_DIR=/frontend # Run the binary CMD ["./api-server"] diff --git a/README.md b/README.md index 9dbef52..fe0af34 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ your score for it, and whether (and when) you finished it. Each account has its own private game list, but all accounts share one platform list (only an admin can add/rename/delete a platform - the first -account ever registered becomes an admin automatically). The API is usable. -There are three small-base clients: a terminal UI, a desktop GUI (Fyne), -and a web app (Angular) — each can log in and show your games, with more -to come. +account ever registered becomes an admin automatically). The API is usable, +and serves its own web UI (see below). There are three small-base clients: +a terminal UI, a desktop GUI (Fyne), and a web app (Angular) — each can log +in and show your games, with more to come. ## Running it @@ -27,7 +27,14 @@ just run-api ``` The API creates its database and runs migrations on startup if needed. -Browse `http://localhost:8080/docs` for the generated API docs. +Browse `http://localhost:8080/docs` for the generated API docs, or +`http://localhost:8080/` for the web UI - the Docker image bakes in a +production build of `web/` and the API serves it itself (see +`registerWebUI` in `cmd/api/main.go`); unknown paths fall back to its +`index.html` so Angular's router handles deep links like `/games`. Running +the API outside Docker (`just run-api`) skips this and serves the API +only, unless `WEB_DIST_DIR` points at a build of `web/` (`just web-build` +outputs one to `web/dist/web/browser`). ## Using the API @@ -58,6 +65,9 @@ also set an httpOnly `session` cookie. - `just web-dev` — a web app (Angular + Material) at `http://localhost:4200`, proxying nothing itself; it talks directly to the API at `localhost:8080` (CORS is permissive enough for local dev as-is). Log in, see your games. + This is for frontend development with hot reload; the API's own `/` (see + above) serves a production build of the same app with nothing extra to + run. All three point at `http://localhost:8080` by default. The TUI and GUI read `COMPLETED_API_URL` to use a different API; the web app's URL is a build-time @@ -87,9 +97,29 @@ shouldn't be hand-edited. ## CI/CD -`.gitea/workflows/ci.yml` builds and tests everything (Go + web) on every -push to `main` or `develop`. `.gitea/workflows/release.yml` builds all three -clients and attaches them to a Gitea release when a `vX.Y.Z` tag is pushed; -see that file's comments for its one real limitation (the desktop GUI is -only built for linux/amd64, since it needs cgo and this repo has no -registered macOS/Windows runner to cross-build it on). +`.gitea/workflows/ci.yml` builds and tests everything on every push to +`main` or `develop`: Go (gofmt/vet/build/test/tidy), sqlc, the web app, and +a Docker build-only check (the same image `release.yml` later pushes). + +`.gitea/workflows/release.yml` runs on a `vX.Y.Z` tag: +- cross-compiles `completed-api`/`completed-tui` for linux, darwin and + windows (amd64 + arm64 where it makes sense - both are pure Go, no cgo) +- cross-compiles the Fyne GUI for linux/amd64 (native) and windows/amd64 + (via mingw-w64; verified locally - a real GUI-subsystem `.exe`, no + console window). macOS isn't built, since cgo has no equivalent + cross-toolchain and this repo has no registered macOS runner +- builds the web app +- builds and pushes the API's Docker image (which embeds that same web + build) to `gitea.sanplex.xyz/sansan/completed`, tagged with the version + and `latest` +- uploads every build artifact to the Gitea generic package registry and + attaches them to a Gitea release + +Both workflows were run for real against this repo - the Go test suite, +`sqlc diff`, `go mod tidy -diff`, the Angular build and test suite, the +Fyne-for-Windows cross-compile, and the actual multi-stage Docker build +(built, run against a real Postgres container, and hit with curl) all +passed. The workflow YAML itself (trigger/job wiring, the Gitea API calls +for uploading packages and creating the release) is written to match this +Gitea instance's other projects, but wasn't run through Gitea Actions +itself from this environment - worth watching the first real run of each. diff --git a/cmd/api/main.go b/cmd/api/main.go index 9dce95a..c23b0ff 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -96,6 +96,45 @@ func runMigrations(dbURL string) error { return nil } +// reservedAPIPrefixes are the paths the API itself owns: everything under +// /api, plus what Huma's default config registers for docs/schemas. The +// web UI's SPA fallback (below) must never shadow these. +var reservedAPIPrefixes = []string{"/api/", "/docs", "/schemas/", "/openapi.json", "/openapi.yaml", "/hello"} + +// registerWebUI serves the Angular build as a single-page app if one has +// been placed at dir (WEB_DIST_DIR, default /frontend - matching where the +// Dockerfile's web build stage copies it to). Unknown paths fall back to +// index.html so Angular's router can handle a direct link to e.g. /games. +// +// Outside the Docker image, dir won't exist; this is a deliberate no-op in +// that case; so `go run ./cmd/api` and the CI build keep working without +// ever needing Node, and the API alone can still be deployed without a UI. +func registerWebUI(e *echo.Echo) { + dir := os.Getenv("WEB_DIST_DIR") + if dir == "" { + dir = "/frontend" + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + fmt.Printf("WEB_DIST_DIR %q not found; serving the API only, no web UI.\n", dir) + return + } + + e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ + Root: dir, + HTML5: true, + Skipper: func(c echo.Context) bool { + p := c.Request().URL.Path + for _, prefix := range reservedAPIPrefixes { + if strings.HasPrefix(p, prefix) { + return true + } + } + return false + }, + })) + fmt.Printf("Serving the web UI from %q.\n", dir) +} + func main() { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { @@ -144,6 +183,7 @@ func main() { humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) api.RegisterRoutes(humaApi, queries) + registerWebUI(e) port := os.Getenv("PORT") if port == "" {