Public Access
The files screen could browse a remote and delete on it, and that was all: there is no browsable local filesystem on Android for a second pane to show, so the gesture the desktop is built around — choose on the left, press the arrow — has nothing to stand on. What replaces it is the platform's own two pickers. ADD FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the selected row. Both stage through the application's cache, and that copy is a requirement rather than a shortcut. android-port.md predicted a picked document would be a third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks, because an upload resumes from the byte the last attempt reached, and a content:// URI has no path behind it, no length worth trusting, no promised seek and no grant that survives the document being edited underneath it. Copying first costs one class in the head and nothing at all in the shared layers, where the alternative was every resume rule rewritten around a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped so RESUME still has something to read, and swept at the next launch — which is the one moment emptying that directory is provably safe, since nothing has queued anything yet. Coming out had a decision going in did not: when to ask where it goes. The save picker is raised before the transfer, so the download runs into the same staging directory and hands its bytes to a callback the head supplied, held against the transfer id so a RETRY still lands where the person pointed. Asking afterwards would put the picker minutes from the button that caused it and, on a phone, usually while the application is backgrounded and Android will not show one at all. The cost is that the picker creates its file when it is dismissed, so a download that then fails leaves an empty one there; that is said on the screen, in the README and in the manual checks rather than left to be discovered. A delivery that fails keeps the staged bytes for the sweep instead of throwing away the one copy of something just fetched over somebody's network. The foreground service counts transfers now, which is the half of it that matters most here: a shell survives backgrounding because somebody is looking at it, and an upload has to survive precisely when nobody is. Queued counts as active, so putting five files in and locking the phone moves five files. The seam was built for this and wired to () => 0 because nothing could fill the queue. Alongside it, ADR 0010 answers the second question android-port.md left open, and it had to be answered before the first release rather than at upload time: a new Play app must use App Bundles and therefore Play App Signing, and an installed app can only be updated by a package signed with the same key, so the first release picks an identity for good. The project holds the key, offline and never in CI — the workflow's package step now says so where somebody would break it — and a DodoSSH deployment never serves the client, because a download link on your own server hands the binary that holds the plaintext to the party the whole threat model is about. The README's M1 gap note was stale in both halves and is replaced by what is actually true: credentials have an editor and a REMEMBER tick, and the device key registers into the TPM under a CNG policy that makes the consent dialog a condition of using it. What is left is the floor rather than a gap — no TPM, or no Windows, means the passphrase on every launch.
550 lines
26 KiB
YAML
550 lines
26 KiB
YAML
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]
|
|
|
|
# Actions are pinned to commit SHAs, not tags: a tag can be moved to point at new code,
|
|
# which would let a compromised action run with this workflow's permissions.
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
DOTNET_NOLOGO: true
|
|
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
|
CI: true
|
|
|
|
jobs:
|
|
build:
|
|
name: build and test
|
|
runs-on: [linux]
|
|
steps:
|
|
# act_runner runs every `uses:` action with node inside the job container, and this
|
|
# runner's image has none — the run died on the first line of actions/checkout with
|
|
# "Cannot find: node in PATH". A `run:` step is shell rather than node, so this one
|
|
# can go first and unblock the rest.
|
|
#
|
|
# This is a workaround and the real fix is one line of the runner's own config.yaml:
|
|
# point container.image at an image that ships node, the way Gitea's default
|
|
# catthehacker/ubuntu:act-latest does. Kept anyway, because a pipeline that depends
|
|
# on a runner being configured correctly somewhere else fails confusingly when it is
|
|
# not, and because it costs nothing on a runner that is.
|
|
#
|
|
# git as well as node, and said in the step name rather than smuggled in: checkout
|
|
# shells out to git the moment node has loaded it, so an image thin enough to lack
|
|
# one usually lacks the other, and learning that costs a whole second CI round trip.
|
|
#
|
|
# Repeated verbatim in all three jobs, which is not laziness. It cannot be a local
|
|
# composite action — that would need the checkout it exists to unblock — and YAML
|
|
# anchors, which would deduplicate it, are rejected by GitHub's parser and would make
|
|
# this file portable to nothing. Change one copy, change all three.
|
|
- name: ensure node and git
|
|
run: |
|
|
set -eu
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
missing=""
|
|
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
|
|
command -v git >/dev/null 2>&1 || missing="$missing git"
|
|
|
|
if [ -z "$missing" ]; then
|
|
echo "node $(node --version), git $(git --version)"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Installing:$missing"
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends $missing
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache $missing
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y $missing
|
|
else
|
|
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
|
|
echo "job. Point the runner's container.image at something that ships node." >&2
|
|
exit 1
|
|
fi
|
|
echo "node $(node --version), git $(git --version)"
|
|
|
|
# Warned about rather than failed on. Distributions pin their nodejs package to
|
|
# the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
|
|
# of life and older than the runtime these actions declare. It generally runs
|
|
# them anyway, since act_runner uses whichever node is on PATH regardless of what
|
|
# the action asked for, so this is a note for when one of them misbehaves in a
|
|
# way that makes no sense, not a reason to stop a build that is probably fine.
|
|
major="$(node --version | sed 's/^v//; s/\..*//')"
|
|
if [ "$major" -lt 20 ]; then
|
|
echo "::warning::node $major is older than the runtime these actions target;" \
|
|
"give the runner an image with node 20 or newer if actions misbehave."
|
|
fi
|
|
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
|
with:
|
|
global-json-file: global.json
|
|
cache: true
|
|
cache-dependency-path: '**/packages.lock.json'
|
|
|
|
# Locked mode fails if packages.lock.json does not match the project files, so a
|
|
# dependency cannot change without the lock file change being reviewed.
|
|
- name: restore
|
|
run: dotnet restore DodoSSH.slnx --locked-mode
|
|
|
|
# No `dotnet format --verify-no-changes` step. It re-analysed the whole solution before
|
|
# the build did, for minutes, to check something the build already checks: IDE0055 is an
|
|
# error in .editorconfig and TreatWarningsAsErrors is on, so a misformatted file fails
|
|
# the build step below on its own. What the separate step added was the ability to say
|
|
# so a few minutes earlier, and it cost more than that on every run.
|
|
|
|
# Avalonia's headless renderer is still Skia, and libSkiaSharp.so — which the layout
|
|
# test project copies into its own output — links against libfontconfig. Without that
|
|
# one library every test in DodoSSH.Client.App.Layout.Tests dies inside
|
|
# HeadlessUnitTestSession before it measures anything, and 69 tests fail for a reason
|
|
# none of their names or assertions mention.
|
|
#
|
|
# The library, and not fonts. Verified in a container where fc-list returns zero and
|
|
# the suite passes anyway: the application carries Inter itself, so nothing here needs
|
|
# a typeface installed — only the thing that would have gone looking for one.
|
|
- name: ensure skia's native dependency
|
|
run: |
|
|
set -eu
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
if ldconfig -p 2>/dev/null | grep -q 'libfontconfig\.so\.1'; then
|
|
echo "libfontconfig present"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Installing fontconfig"
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends libfontconfig1
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache fontconfig
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y fontconfig
|
|
else
|
|
echo "No package manager here, so Skia cannot be given its dependency and the" >&2
|
|
echo "layout suite will fail to start. Add fontconfig to the runner's image." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: build
|
|
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
|
|
|
- name: test
|
|
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
|
|
|
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
|
# server through Testcontainers and runs the API as a child process — so it needs a
|
|
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
|
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
|
|
# dominate a cold run.
|
|
|
|
# A failing run says only that tests failed and names a log file on a machine nobody
|
|
# has a shell on. Every diagnostic thing — exception type, message, stack — is inside
|
|
# that file, so a red build was a filename and a guess. This prints it.
|
|
#
|
|
# head rather than tail, and that is the whole trick: when a suite fails wholesale it
|
|
# writes one stack per test and they are all the same stack. The first is the one that
|
|
# explains it, and the last two hundred lines are the same sentence repeated.
|
|
- name: what actually failed
|
|
if: failure()
|
|
run: |
|
|
set +e
|
|
echo "=== distro ==="
|
|
cat /etc/os-release 2>/dev/null | head -3
|
|
id
|
|
|
|
echo "=== what Skia needs, and whether it is here ==="
|
|
# ldd against the copy the test project carries. Its unresolved rows are the
|
|
# answer whenever the layout suite dies in HeadlessUnitTestSession, and asking
|
|
# here beats inferring it from a managed TypeInitializationException.
|
|
skia="$(find tests -name 'libSkiaSharp.so' 2>/dev/null | head -1)"
|
|
if [ -n "$skia" ]; then
|
|
echo "$skia"
|
|
ldd "$skia" 2>&1 | grep -Ei 'not found|fontconfig|freetype' || echo " all resolved"
|
|
else
|
|
echo " libSkiaSharp.so was not in the test output at all"
|
|
fi
|
|
ldconfig -p 2>/dev/null | grep -ci fontconfig | sed 's/^/fontconfig entries in ldconfig: /'
|
|
|
|
echo "=== docker, for the Testcontainers suites ==="
|
|
docker version --format '{{.Server.Version}}' 2>&1 | head -2
|
|
|
|
echo "=== test logs ==="
|
|
find tests -path '*/TestResults/*.log' 2>/dev/null | while read -r log; do
|
|
echo "----- $log"
|
|
head -n 120 "$log"
|
|
done
|
|
exit 0
|
|
android:
|
|
name: android head
|
|
runs-on: [linux]
|
|
steps:
|
|
# Duplicated from the build job; see the comment there for why it cannot be factored
|
|
# out. Any change here has to be made in all three.
|
|
- name: ensure node and git
|
|
run: |
|
|
set -eu
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
missing=""
|
|
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
|
|
command -v git >/dev/null 2>&1 || missing="$missing git"
|
|
|
|
if [ -z "$missing" ]; then
|
|
echo "node $(node --version), git $(git --version)"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Installing:$missing"
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends $missing
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache $missing
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y $missing
|
|
else
|
|
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
|
|
echo "job. Point the runner's container.image at something that ships node." >&2
|
|
exit 1
|
|
fi
|
|
echo "node $(node --version), git $(git --version)"
|
|
|
|
# Warned about rather than failed on. Distributions pin their nodejs package to
|
|
# the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
|
|
# of life and older than the runtime these actions declare. It generally runs
|
|
# them anyway, since act_runner uses whichever node is on PATH regardless of what
|
|
# the action asked for, so this is a note for when one of them misbehaves in a
|
|
# way that makes no sense, not a reason to stop a build that is probably fine.
|
|
major="$(node --version | sed 's/^v//; s/\..*//')"
|
|
if [ "$major" -lt 20 ]; then
|
|
echo "::warning::node $major is older than the runtime these actions target;" \
|
|
"give the runner an image with node 20 or newer if actions misbehave."
|
|
fi
|
|
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
|
with:
|
|
global-json-file: global.json
|
|
cache: true
|
|
cache-dependency-path: '**/packages.lock.json'
|
|
|
|
# A job of its own, because DodoSSH.Client.Android is deliberately not in DodoSSH.slnx.
|
|
# Adding it there would make the android workload and a full Android SDK a prerequisite of
|
|
# `dotnet build DodoSSH.slnx` for everyone — including the build job above, which needs
|
|
# neither and would grow several minutes for a head it does not compile.
|
|
#
|
|
# The cost of keeping it out is that nothing in the main job would notice this head
|
|
# breaking, which for a project sharing view models with the desktop one is a matter of
|
|
# when rather than whether. This job is that notice.
|
|
- name: install the android workload
|
|
run: dotnet workload install android --skip-sign-check
|
|
|
|
# API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a
|
|
# net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its
|
|
# terminal. See docs/android-port.md.
|
|
- name: install the android sdk platform
|
|
run: |
|
|
echo "y" | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
|
"platforms;android-36" "build-tools;36.0.0"
|
|
|
|
- name: restore
|
|
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
|
|
|
|
- name: build
|
|
run: >
|
|
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
|
--no-restore --configuration Release
|
|
|
|
# Packaging rather than only compiling, because the two failures this head is most exposed to
|
|
# are both link-time: a native library with no android ABI, and a managed assembly that
|
|
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
|
#
|
|
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
|
|
# docs/adr/0010-android-distribution.md puts the release key on a machine that is not a runner,
|
|
# because a signing key reachable from a workflow is a key held by everyone who can change one.
|
|
# This APK is a build check. It is not something anybody installs.
|
|
- name: package
|
|
run: >
|
|
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:
|
|
# Duplicated from the build job; see the comment there for why it cannot be factored
|
|
# out. Any change here has to be made in all three.
|
|
- name: ensure node and git
|
|
run: |
|
|
set -eu
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
missing=""
|
|
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
|
|
command -v git >/dev/null 2>&1 || missing="$missing git"
|
|
|
|
if [ -z "$missing" ]; then
|
|
echo "node $(node --version), git $(git --version)"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Installing:$missing"
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends $missing
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache $missing
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y $missing
|
|
else
|
|
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
|
|
echo "job. Point the runner's container.image at something that ships node." >&2
|
|
exit 1
|
|
fi
|
|
echo "node $(node --version), git $(git --version)"
|
|
|
|
# Warned about rather than failed on. Distributions pin their nodejs package to
|
|
# the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
|
|
# of life and older than the runtime these actions declare. It generally runs
|
|
# them anyway, since act_runner uses whichever node is on PATH regardless of what
|
|
# the action asked for, so this is a note for when one of them misbehaves in a
|
|
# way that makes no sense, not a reason to stop a build that is probably fine.
|
|
major="$(node --version | sed 's/^v//; s/\..*//')"
|
|
if [ "$major" -lt 20 ]; then
|
|
echo "::warning::node $major is older than the runtime these actions target;" \
|
|
"give the runner an image with node 20 or newer if actions misbehave."
|
|
fi
|
|
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
# The daemon and the client are two different things to have, and this runner had only
|
|
# one of them. Testcontainers reaches Docker straight over /var/run/docker.sock from a
|
|
# .NET library, so every integration suite in the build job passed while `docker` was
|
|
# not a command here at all — which surfaced as exit 127 from the build step below,
|
|
# after the tags had been worked out and everything looked healthy.
|
|
#
|
|
# Only the CLI. The socket is already there and a daemon is already answering on it;
|
|
# installing an engine would start a second one beside the one being used.
|
|
- name: ensure the docker cli
|
|
run: |
|
|
set -eu
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
echo "Installing the docker cli"
|
|
if command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache docker-cli
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends docker.io
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y docker-cli
|
|
else
|
|
echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2
|
|
echo "inside the job. Add it to the runner's image." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
docker --version
|
|
|
|
# buildx after the client, and wanted rather than required. Without the plugin
|
|
# `docker build` falls back to the legacy builder, which still produces the image
|
|
# and says on every run that it will not do so forever; with it the same command
|
|
# routes through BuildKit and the Dockerfile's independent stages stop being
|
|
# serialised. Alpine's docker-cli package does not carry it, which is why a job
|
|
# that had just been given a working client still built the deprecated way.
|
|
#
|
|
# A distribution with no package for it should get a warning and an image, not a
|
|
# failed release — so every branch here ends in `|| true` and the check below
|
|
# reports rather than exits.
|
|
if ! docker buildx version >/dev/null 2>&1; then
|
|
echo "Installing buildx"
|
|
if command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache docker-cli-buildx || true
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq || true
|
|
$SUDO apt-get install -y --no-install-recommends docker-buildx || true
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y docker-buildx || true
|
|
fi
|
|
fi
|
|
|
|
if docker buildx version >/dev/null 2>&1; then
|
|
docker buildx version
|
|
else
|
|
echo "::warning::buildx is unavailable, so this image was built by the legacy" \
|
|
"builder Docker has deprecated. Add a buildx package to the runner image."
|
|
fi
|
|
|
|
# No docker/* actions here, deliberately. The build is single-architecture, so it
|
|
# wants the daemon this runner already has, a client, and BuildKit — all of which the
|
|
# step above arranges with two packages. What it does not want is QEMU, a builder
|
|
# instance to create and tear down, or a third-party action whose SHA has to be
|
|
# audited and re-pinned on a schedule. Adding linux/arm64 later is where that trade
|
|
# changes, and where setup-buildx-action starts earning its place.
|
|
- 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 -eu
|
|
|
|
# Checked before use, because an unset secret is not an error anywhere upstream of
|
|
# here: an expression that resolves to nothing renders as the empty string, so
|
|
# docker is handed --username "" and answers with something about credentials,
|
|
# which sends people to the registry to debug a value that never left the
|
|
# settings page.
|
|
#
|
|
# Note for anyone editing this comment: an expression delimiter written literally
|
|
# here is interpolated even though this is a shell comment. The runner substitutes
|
|
# the whole script before any shell sees it, so an empty one fails the step with a
|
|
# parse error and no line number — which is how this very block broke the release
|
|
# it was added to protect.
|
|
#
|
|
# Reported by length, and never by value. Gitea masks known secret values in logs,
|
|
# but a mask is only as good as the runner's bookkeeping and a length answers the
|
|
# only question being asked: did anything arrive.
|
|
missing=""
|
|
[ -n "${REGISTRY_USERNAME:-}" ] || missing="$missing REGISTRY_USERNAME"
|
|
[ -n "${REGISTRY_PASSWORD:-}" ] || missing="$missing REGISTRY_PASSWORD"
|
|
|
|
if [ -n "$missing" ]; then
|
|
echo "Empty or unset:$missing" >&2
|
|
echo >&2
|
|
echo "Both come from repository secrets, which in Gitea are at" >&2
|
|
echo " Settings -> Actions -> Secrets" >&2
|
|
echo "and are a different page from Settings -> Actions -> Variables. A value" >&2
|
|
echo "added as a variable is invisible to the secrets context and arrives here" >&2
|
|
echo "as an empty string, which is exactly what this message means." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
|
|
|
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
|