Give the API an image, and unbreak the restore that had to run first

registry-docker.dodotech.cloud/dodotech/dodossh-api, built and pushed by a third ci job
that needs the first. Gating on the tests costs a few minutes on every main commit and buys
the only thing worth having here: an image is not an artefact somebody inspects before
using it, so a red commit must not be able to produce one. Pull requests build the image
and stop, which is where a broken Dockerfile should be found.

Tags are :sha-<short> on every build, :main on main, and for a v* tag :1.2.3, :1.2 and
:latest — the last two only when the version has no prerelease suffix, since v1.3.0-rc1
sorts above v1.2.9 and would otherwise walk :latest onto somebody's server. Only sha- is
immutable, and it is the one to pin a deployment to.

No docker/* actions. The build is single-architecture, so it needs the daemon this runner
already has for the Testcontainers suites and nothing else — no buildx, no QEMU, and no
third-party action whose SHA has to be audited and re-pinned. Step outputs and secrets
reach the shell through env rather than ${{ }} interpolation, because a git tag may contain
a semicolon and interpolation is textual substitution performed before the shell parses the
line.

The image is chiseled: no shell, no package manager, uid 1654. Affordable because
Directory.Build.props already sets InvariantGlobalization, so the ICU and tzdata a normal
base carries are exactly what this product decided not to use. The cost is stated in the
Dockerfile rather than hidden — there is no HEALTHCHECK, because there is nothing to run
one with, and /healthz/ready is anonymous precisely so the orchestrator can ask instead.
Nothing migrates the schema from inside the container either; readiness fails while a
migration is pending and names it, which is the design.

And the restore that all of this depends on did not work. 7a3a521 committed lock files
carrying a net10.0/android-arm64 section into fourteen projects — written there by the
Android head's -p:RuntimeIdentifier=android-arm64 packaging build, which restores the
shared projects with a RID and updates their lock files as a side effect. Any restore
without that RID then fails NU1004 in locked mode, which is every other build there is:
`dotnet restore DodoSSH.slnx --locked-mode` has been failing for eleven projects on a clean
checkout of main since that commit. The sections are removed here and nothing else changed
— deletions only, ILLink.Tasks stays at 10.0.10.

Verified: the solution restores in locked mode, the image builds, and it runs. /healthz/live
answers 200 and /healthz/ready answers 503 naming the database it cannot reach, from a
67 MB image as uid 1654, configured entirely through DODOSSH_-prefixed variables.

The Android head's own lock file still carries the RID and is untouched, because that job
restores it separately and is outside DodoSSH.slnx. Whether its packaging step re-dirties
these fourteen on every CI run is worth a look; it is the same mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 13:40:12 +02:00
co-authored by Claude Opus 5
parent 215e73b07f
commit 8a568117df
17 changed files with 269 additions and 112 deletions
+123
View File
@@ -3,6 +3,10 @@ name: ci
on:
push:
branches: [main]
# Release tags run the whole workflow, not only the image job that gates on it. A tag
# is the one build nobody is watching, so it is the last place to take the tests on
# trust.
tags: ['v*']
pull_request:
branches: [main]
@@ -100,3 +104,122 @@ jobs:
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
--no-restore --configuration Release
-t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64
image:
name: api image
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
# clock on every main commit and buys the thing worth having: no image reaches the
# registry from a commit whose tests were red. An image is not a build artefact anyone
# inspects — it is the thing that gets deployed.
needs: [build]
runs-on: [linux]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# No docker/* actions here, deliberately. The build is single-architecture, so it
# needs the daemon this runner already has for the Testcontainers suites and nothing
# else — no buildx plugin, no QEMU, and no third-party action whose SHA has to be
# audited and re-pinned. Adding linux/arm64 later is where that trade changes.
- name: work out the tags
id: tags
env:
REGISTRY: registry-docker.dodotech.cloud
IMAGE: dodotech/dodossh-api
run: |
set -euo pipefail
repo="$REGISTRY/$IMAGE"
short="$(git rev-parse --short HEAD)"
# sha- prefixed, because a bare hex tag is ambiguous with a digest to both a human
# and a fair amount of tooling. This one is on every build and never moves, which
# makes it the only tag safe to pin a deployment to.
tags="$repo:sha-$short"
version="$short"
case "$GITHUB_REF" in
refs/tags/v*)
v="${GITHUB_REF#refs/tags/v}"
version="$v"
tags="$tags $repo:$v"
# The moving major.minor tag and :latest, but only for a release proper.
# v1.3.0-rc1 sorts after v1.2.9 and would otherwise take :latest with it,
# which is how a release candidate ends up on somebody's server.
case "$v" in
*-*) ;;
*)
tags="$tags $repo:${v%.*}"
tags="$tags $repo:latest"
;;
esac
;;
refs/heads/main)
tags="$tags $repo:main"
version="main-$short"
;;
esac
echo "tags=$tags" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
echo "Tagging: $tags"
# Every value from a step output or the event goes through env rather than being
# interpolated into the script text. A git tag may contain a semicolon, and
# `${{ }}` is a textual substitution performed before the shell ever sees the line —
# so an interpolated tag name is a command the workflow agreed to run.
- name: build the image
env:
TAGS: ${{ steps.tags.outputs.tags }}
VERSION: ${{ steps.tags.outputs.version }}
REVISION: ${{ github.sha }}
CREATED: ${{ steps.tags.outputs.created }}
run: |
set -euo pipefail
args=()
for tag in $TAGS; do
args+=(--tag "$tag")
done
# --pull rather than whatever the runner happens to have cached: the base images
# are floating tags, and a runner that has held aspnet:10.0-noble-chiseled for a
# month is a month of unapplied CVE fixes shipping in every image built on it.
docker build \
--pull \
--file src/DodoSSH.Api/Dockerfile \
--build-arg "VERSION=$VERSION" \
--build-arg "REVISION=$REVISION" \
--build-arg "CREATED=$CREATED" \
"${args[@]}" \
.
# Everything above runs on a pull request too. Building a fork's Dockerfile is safe —
# nothing is pushed and no credential is in scope — and it means a change that breaks
# the image fails on the PR rather than on main. Only these last two steps are held
# back, and the condition is on the event rather than on the branch so that a PR
# targeting main cannot reach them.
- name: log in to the registry
if: github.event_name != 'pull_request'
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
printf '%s' "$REGISTRY_PASSWORD" \
| docker login registry-docker.dodotech.cloud \
--username "$REGISTRY_USERNAME" --password-stdin
- name: push
if: github.event_name != 'pull_request'
env:
TAGS: ${{ steps.tags.outputs.tags }}
run: |
set -euo pipefail
for tag in $TAGS; do
docker push "$tag"
done
# The daemon is shared with every other job on this runner, and a credential left in
# ~/.docker/config.json outlives the job that created it. always(), so a failed push
# does not leave it behind.
- name: log out
if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud