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
107 changes: 107 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Publish Docker image

# Tag-triggered. Mirrors publish.yml (npm) on cadence so a single
# release tag fans out to both the npm SignalK webapp bundle and the
# standalone Docker image. Pre-release tags (v*-alpha / v*-beta /
# v*-rc) publish under the version tag but skip the :latest move.

on:
push:
tags:
- 'v*'

permissions:
contents: read
packages: write
id-token: write
attestations: write

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
# Native runners per arch - free for public repos, much
# faster than QEMU emulation under ubuntu-latest. Each job
# publishes an arch-tagged image; the manifest job stitches
# them into a multi-arch tag.
- { arch: amd64, runner: ubuntu-latest }
- { arch: arm64, runner: ubuntu-24.04-arm }
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute version + image tags
id: meta
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/}"
version="${tag#v}"
repo_lower="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "image=ghcr.io/${repo_lower}" >> "$GITHUB_OUTPUT"
echo "arch_tag=ghcr.io/${repo_lower}:${version}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"

- name: Build and push (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/${{ matrix.arch }}
tags: ${{ steps.meta.outputs.arch_tag }}
provenance: true
# GitHub Actions cache: arch-scoped so amd64 and arm64
# don't trample each other.
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}

manifest:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compose multi-arch manifest
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/}"
version="${tag#v}"
repo_lower="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${repo_lower}"

# The unversioned :latest tag only moves for stable
# releases. Pre-release suffixes (alpha/beta/rc) still
# get :version published but won't pull as :latest, so a
# `docker pull <image>:latest` stays on the last GA.
latest_args=()
if [[ "$version" != *alpha* && "$version" != *beta* && "$version" != *rc* ]]; then
latest_args=(--tag "${image}:latest")
fi

docker buildx imagetools create \
--tag "${image}:${version}" \
"${latest_args[@]}" \
"${image}:${version}-amd64" \
"${image}:${version}-arm64"
6 changes: 6 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ jobs:
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'

# esbuild is required by the MinifyPublishedJs target in
# OnaPlotter.csproj. The Release publish below fails with a
# clear MSBuild error if node_modules/esbuild is missing.
- name: Install JS toolchain (esbuild)
run: npm ci

- name: Publish OnaPlotter (Release + AOT)
run: dotnet publish OnaPlotter/OnaPlotter.csproj -c Release -o publish

Expand Down
91 changes: 91 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# syntax=docker/dockerfile:1.7
#
# Multi-stage build for OnaPlotter as a standalone web server. Two
# AOTs in play - WASM AOT compiles the Blazor client to WebAssembly
# (slow), NativeAOT compiles the Kestrel host to a self-contained
# native binary. Final image lands around 35 to 50 MB depending on
# arch and WASM payload size, on top of the ~10 MB Alpine
# runtime-deps base.
#
# Image layout: /app/OnaPlotter.Server (the native binary) and
# /app/wwwroot (the Blazor static bundle). The host reads SK_SERVER_URL
# and BASE_HREF env vars at startup and templates wwwroot/appsettings.json
# and wwwroot/index.html before the first request - see
# OnaPlotter.Server/Program.cs ApplyRuntimeConfig.
#
# Run:
# docker run -p 8080:8080 \
# -e SK_SERVER_URL=https://my-sk:3000 \
# ghcr.io/msallin/ona-plotter:<version>

ARG DOTNET_VERSION=10.0

# -------- Build stage --------
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-alpine AS build

# NativeAOT toolchain prerequisites on Alpine. The .NET SDK image
# ships without a native C toolchain so we install it here once.
# build-base pulls gcc + binutils + libc-dev; clang + lld give
# NativeAOT a usable linker; zlib-dev is the one runtime dep the
# linker actually wants. nodejs + npm satisfy the esbuild step
# (the MinifyPublishedJs MSBuild target in OnaPlotter.csproj runs
# `npx esbuild` against the JS interop layer before the bundle is
# copied to /app/publish).
RUN apk add --no-cache build-base clang lld zlib-dev nodejs npm

WORKDIR /src

# JS deps first - small layer, rarely changes once esbuild + lint
# tooling pins are stable. Restored before .NET so an esbuild bump
# doesn't bust the dotnet restore cache.
COPY package.json package-lock.json ./
RUN npm ci --omit=optional

# .NET restore next, again as its own layer. Copying just the
# project files (not sources) lets a code change reuse the restored
# package cache.
COPY OnaPlotter.slnx global.json ./
COPY OnaPlotter/OnaPlotter.csproj OnaPlotter/
COPY OnaPlotter.Server/OnaPlotter.Server.csproj OnaPlotter.Server/
RUN dotnet workload install wasm-tools \
&& dotnet restore OnaPlotter.Server/OnaPlotter.Server.csproj

# Source. Order keeps the layer cache: only this and later layers
# rebuild on a source change.
COPY OnaPlotter/ OnaPlotter/
COPY OnaPlotter.Server/ OnaPlotter.Server/

# Publish. Hits both AOTs in one invocation:
# 1. OnaPlotter (the WASM client) - IL to WebAssembly,
# InvariantGlobalization + WasmStripILAfterAOT + Speed
# optimisation from its own csproj.
# 2. OnaPlotter.Server (the Kestrel host) - IL to native code
# for the container arch, single-file self-contained.
# Both projects' AOT settings already gate on Configuration=Release,
# so this is the only switch needed.
RUN dotnet publish OnaPlotter.Server/OnaPlotter.Server.csproj \
-c Release \
-o /app/publish

# -------- Runtime stage --------
FROM mcr.microsoft.com/dotnet/runtime-deps:${DOTNET_VERSION}-alpine

WORKDIR /app

# --chown so the non-root user can write templated wwwroot files
# at container start. ApplyRuntimeConfig mutates appsettings.json
# and index.html from env vars; both live under wwwroot.
COPY --from=build --chown=$APP_UID:$APP_UID /app/publish ./

ENV ASPNETCORE_URLS=http://+:8080 \
DOTNET_RUNNING_IN_CONTAINER=true

EXPOSE 8080

# runtime-deps:alpine sets APP_UID=1654 as a non-root user. Reuse
# it rather than running as root.
USER $APP_UID

# Native binary; no `dotnet` prefix because PublishAot produces a
# self-contained executable.
ENTRYPOINT ["./OnaPlotter.Server"]
42 changes: 42 additions & 0 deletions OnaPlotter.Server/OnaPlotter.Server.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- NativeAOT slashes the runtime image: a self-contained
executable of ~12 to 15 MB on linux-x64 vs the ~70 MB
framework-dependent baseline, so the final Docker image
lands around 35 to 50 MB on top of the ~10 MB
runtime-deps:alpine base. Trade-off is a longer publish
(the cross-compiler + linker step adds 1 to 3 min on top of
the WASM AOT compilation in the referenced OnaPlotter
project). Gate on Release so `dotnet run` and Debug
publishes stay fast for local dev. -->
<PublishAot Condition="'$(Configuration)' == 'Release'">true</PublishAot>
<!-- Smaller binary; matches the WASM client's invariant culture
posture so a Spanish helm sees the same number formatting
in standalone server logs as the WASM bundle uses. -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Strip native debug symbols out of the published binary on
Release. Keeps the container image lean; if a crash needs
symbolicated stack traces, rebuild with /p:StripSymbols=false
locally. -->
<StripSymbols Condition="'$(Configuration)' == 'Release'">true</StripSymbols>
</PropertyGroup>

<ItemGroup>
<!-- Hosts the published wwwroot from the Blazor project at the
root of the Kestrel content tree. The transitive reference
+ the framework files extension below give us index.html,
the _framework/ AOT WASM assets, and the wwwroot/js interop
layer in a single publish artifact. -->
<ProjectReference Include="..\OnaPlotter\OnaPlotter.csproj" />
<!-- UseBlazorFrameworkFiles() lives here. Pulls in the static
file middleware tuned for Blazor's _framework/ directory
(correct MIME types, cache headers, brotli precompressed
negotiation). -->
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.*-*" />
</ItemGroup>

</Project>
Loading
Loading