From 958a3f056dc4fc69c4684d34d8cb06a14e1227b8 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 28 Oct 2026 08:56:51 -0700 Subject: [PATCH] Add GHCR Docker publishing and ZIP releases --- .dockerignore | 10 +++++++ .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++--- Dockerfile | 8 ++++++ README.md | 56 +++++++++++++++++++++++++++++++++++++--- compose.yaml | 15 +++++++++++ docker/appsettings.json | 15 +++++++++++ 6 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docker/appsettings.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7bf2dac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +** +!artifacts/ +!artifacts/portable/ +!artifacts/portable/** +!docker/ +!docker/appsettings.json +**/*.db +**/*.db-* +**/*.env +**/appsettings.*.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c461eb0..cb40bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,18 +49,63 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') env: RELEASE_TAG: ${{ github.ref_name }} - run: tar -czf "artifacts/indirection-${RELEASE_TAG}.tar.gz" -C artifacts/portable . + run: | + cd artifacts/portable + zip -r "../indirection-${RELEASE_TAG}.zip" . - uses: actions/upload-artifact@v4 if: startsWith(github.ref, 'refs/tags/v') with: name: release-portable - path: artifacts/indirection-*.tar.gz + path: artifacts/indirection-*.zip if-no-files-found: error - release: + docker: needs: test if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: release-portable + path: artifacts + - name: Extract published application + env: + RELEASE_TAG: ${{ github.ref_name }} + run: unzip "artifacts/indirection-${RELEASE_TAG}.zip" -d artifacts/portable + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v6 + id: meta + with: + images: ghcr.io/bpoe/indirection + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + release: + needs: [test, docker] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest permissions: contents: write steps: @@ -75,5 +120,5 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: >- gh release create "$RELEASE_TAG" - "artifacts/indirection-${RELEASE_TAG}.tar.gz" + "artifacts/indirection-${RELEASE_TAG}.zip" --verify-tag --title "$RELEASE_TAG" --generate-notes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de33ec5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0-distroless +USER $APP_UID +WORKDIR /data +WORKDIR /app +COPY artifacts/portable/ ./ +COPY docker/appsettings.json ./appsettings.json +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Indirection.dll"] diff --git a/README.md b/README.md index ade998e..8c761e1 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The suite checks health and the complete create/read/redirect/update/redirect/de GitHub Actions runs on pull requests targeting `main` and pushes to `main`. It installs .NET 10, caches NuGet packages using the project/configuration files as the cache key, restores, builds the complete solution in Release, and runs only `Indirection.Tests` with the existing coverage configuration. Both line and branch coverage must reach 96%, exceeding the design's >95% requirement; coverage reports are retained as workflow artifacts. -Pushing a version tag matching `v*` runs the same validation before publishing a GitHub Release. After these workflow changes are committed and pushed, create the first release from the intended commit: +Pushing a version tag matching `v*` runs the same validation before publishing a multi-platform image to GHCR and then a GitHub Release. Release creation requires successful tests and Docker publishing. After these workflow changes are committed and pushed, create the first release from the intended commit: ```bash git switch main @@ -80,13 +80,13 @@ git tag v0.1.0 git push origin v0.1.0 ``` -The release attaches `indirection-v0.1.0.tar.gz` (named for the tag). It contains the published application, default configuration, and dependencies, including native SQLite assets for multiple platforms; it contains no database or credentials. Publishing uses GitHub's built-in token: validation has `contents: read`, and only the release-creation job has `contents: write`. No personal access token is needed. +The release attaches `indirection-v0.1.0.zip` (named for the tag). It contains the published application, default configuration, and dependencies, including native SQLite assets for multiple platforms; it contains no database or credentials. Publishing uses GitHub's built-in token: validation has `contents: read`, the Docker job alone has `packages: write`, and only the release-creation job has `contents: write`. No personal access token is needed. The archive is runtime-neutral and framework-dependent. It requires the **ASP.NET Core Runtime 10.0** (including the .NET runtime), not the SDK. One archive serves platforms supported by both .NET 10 and the packaged SQLite libraries, including Windows, Linux, and macOS on x64/Arm64. Keep the `runtimes` directory intact. Extract into an application directory and run from there; the database directory must already exist on persistent storage and be writable by the application account. This example uses Bash; on Windows, set the same environment variables with PowerShell and use an absolute Windows database path: ```bash mkdir -p indirection -tar -xzf indirection-v0.1.0.tar.gz -C indirection +unzip indirection-v0.1.0.zip -d indirection cd indirection export DOTNET_ENVIRONMENT=Production export Authentication__ApiKey="$(openssl rand -base64 32)" # Or supply your existing deployment secret. @@ -97,3 +97,53 @@ dotnet Indirection.dll --urls http://127.0.0.1:5080 ``` The `--migrate` command initializes or upgrades the database without a source checkout, SDK, or platform-specific migration executable. It uses the same configuration as the server, applies only pending migrations, and exits without starting HTTP; failures return a nonzero exit code. Migration mode does not require an API key. Back up the database and stop the application before upgrading, run the new release's migration command, then restart. Keep state outside the extracted release directory and expose deployed management requests through HTTPS as described above. + +Docker images support Linux amd64 and arm64 and use the .NET 10 Azure Linux distroless ASP.NET Core runtime. CI packages the same published binaries from the release ZIP into both images without restoring, compiling, or publishing inside Docker. Pull the published image or publish once locally and package it from the repository root: + +```bash +docker pull ghcr.io/bpoe/indirection:latest +dotnet publish src/prod/Indirection -c Release --self-contained false \ + -p:UseAppHost=false -p:DebugType=None -p:DebugSymbols=false -o artifacts/portable +docker build -t indirection:local . +# Optional multi-platform build (requires a Buildx builder): +docker buildx build --platform linux/amd64,linux/arm64 -t indirection:local --output type=oci,dest=indirection.oci.tar . +``` + +A stable `v0.1.0` tag publishes `ghcr.io/bpoe/indirection` tags `0.1.0`, `0.1`, `0`, and `latest`. Prereleases publish their full version (for example `0.2.0-rc.1`) without updating stable aliases or `latest`. Pin a full version for controlled upgrades. Publishing uses Docker's [metadata-action](https://github.com/docker/metadata-action) for version tags and [Buildx](https://github.com/docker/build-push-action) for multi-platform builds and caching. + +The included `compose.yaml` exposes HTTP on port 8080 and keeps SQLite in a named volume mounted at `/data`. Supply an API key at runtime, apply migrations explicitly, then start: + +```bash +export Authentication__ApiKey="$(openssl rand -base64 32)" # Or your existing deployment secret. +docker compose pull +docker compose run --rm --no-deps indirection --migrate +docker compose up -d +curl --fail http://localhost:8080/health +``` + +Keep the key in your deployment's secret store and supply it again when recreating the container. Migrations do not require a key. Containers run as the runtime image's built-in non-root user, with `/app` as the working directory and a writable `/data`. The runtime contains no shell or package manager. Startup never applies migrations automatically. + +The container includes `docker/appsettings.json` as `/app/appsettings.json`, defaulting to SQLite with `Data Source=/data/urls.db`. The portable ZIP retains its original `Data Source=urls.db` default. To override the complete container configuration, copy `docker/appsettings.json` to a local `appsettings.json`, edit it, and uncomment the read-only file mount in `compose.yaml`. For a direct Docker run, use `--mount type=bind,src="$PWD/appsettings.json",dst=/app/appsettings.json,readonly` on both migration and server containers. Standard ASP.NET Core configuration precedence applies, including environment variables and command-line arguments; neither the image nor Compose sets database-provider or connection-string environment variables. A configured alternative database path is used as supplied; mount writable persistent storage at that location, accessible to UID 1654. + +Without Compose, use the same named volume for migrations and serving: + +```bash +docker volume create indirection-data +docker run --rm -v indirection-data:/data ghcr.io/bpoe/indirection:latest --migrate +docker run -d --name indirection --restart unless-stopped -p 8080:8080 \ + -e Authentication__ApiKey -v indirection-data:/data ghcr.io/bpoe/indirection:latest +``` + +Mount the entire data directory so SQLite journal, WAL, and shared-memory files persist with the database. A fresh named volume inherits the image directory's non-root ownership; provision bind mounts or existing volumes with equivalent write permissions. Back up SQLite consistently while the service is stopped, including its sidecar files. `docker compose down` retains the data volume; `docker compose down -v` deletes it. + +To upgrade, stop the service, back up the database, pull the new image, explicitly migrate using that image, and recreate the container while retaining the volume and API key: + +```bash +docker compose stop +# Back up the data volume before continuing. +docker compose pull +docker compose run --rm --no-deps indirection --migrate +docker compose up -d +``` + +If using a pinned image tag, update it in `compose.yaml` before pulling. Check `/health` and the deployment verification suite after upgrading. Route deployed HTTP through HTTPS as described above. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5e5f81f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + indirection: + image: ghcr.io/bpoe/indirection:latest + ports: + - "8080:8080" + environment: + - Authentication__ApiKey + volumes: + - data:/data + # Optional complete configuration override: + # - ./appsettings.json:/app/appsettings.json:ro + restart: unless-stopped + +volumes: + data: diff --git a/docker/appsettings.json b/docker/appsettings.json new file mode 100644 index 0000000..c98b4ff --- /dev/null +++ b/docker/appsettings.json @@ -0,0 +1,15 @@ +{ + "Database": { + "Provider": "Sqlite" + }, + "ConnectionStrings": { + "Urls": "Data Source=/data/urls.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}