Compare commits

...
Author SHA1 Message Date
dsh-relay 0af110766d ci: pick the webserver bind address from the installed schema
Sync upstream and build images / sync-and-build (push) Successful in 38m42s
The 0.2.1-alpha.2 fix rendered `host` onto the container's own address
unconditionally. Every earlier release still validates it against the union of
the two literals 127.0.0.1 and 0.0.0.0, so those images now die in the boot
smoke test with

  @deepseek-ai/dsh-host-webserver: ValidationError: invalid config:
    - $.host expected "127.0.0.1" | "0.0.0.0" but got "172.17.0.22" (at host)

and their refresh rebuilds -- which the recipe-hash change re-queued anyway --
can no longer succeed. Probe the installed webserver schema instead: where it
rejects the unspecified address render the container's own IPv4, otherwise keep
0.0.0.0 so older versions build exactly as they did before. The probe is
overridable through DSH_INSTALL_ROOT so both branches are testable.
2026-10-13 21:09:56 -04:00
dsh-relay 8363be933d Bind the web UI to the container's own address: dsh 0.2.1+ rejects 0.0.0.0
Sync upstream and build images / sync-and-build (push) Successful in 24m26s
dsh 0.2.1-alpha.2 validates `webserver.host` in a schemastery transform and
throws on the unspecified address ("is an unspecified (wildcard) address,
which is not supported: binding every interface would expose remote code
execution to the network; bind one concrete IPv4 or IPv6 address of a local
interface instead"). The check is config-level, so the --patch opt-in that
carried 0.0.0.0 through the 0.1.x schema no longer works, and every build of
0.2.1-alpha.2 has failed the boot smoke test before the image could be pushed
(the tag exists, so the release page has no image and no notes).

The shipped patch becomes a template; docker-entrypoint.sh renders it onto the
container's first non-internal IPv4 (the same address the smoke test probes)
and passes the rendered copy to dsh, so a port published on the Docker host
still reaches the UI while the socket binds one concrete interface. Set
DSH_WEB_HOST to override the address. The substitution also accepts the old
literal `host: 0.0.0.0` template, so either file can land first.
2026-10-13 20:45:00 -04:00
mward4 128ca9d8b3 ci: only a never-published version can fail the sync run
Sync upstream and build images / sync-and-build (push) Successful in 16m13s
Runs have been red since 2026-09-19 even though every release shipped: the
plan step re-plans the oldest 11 versions as "stale" on every run (pnpm and
the node base image moved on), and those versions can no longer be rebuilt at
all — their floating dependency ranges now resolve to @deepseek-ai packages
that have moved past them, so they fail the boot smoke test ~67s in. Step 8
then failed the run for them, and every 6-hour run went red for ~13 minutes of
builds that could never succeed. A green run meant nothing, and a red one no
longer pointed at anything real.

Split the plan into two kinds of build and treat them differently:

  NEW     - the registry has no image for this version. This is an upstream
            release that has not shipped here yet, which is the whole job.
  REFRESH - the registry already has the image; it is only being moved onto a
            newer pnpm/base/recipe. Best-effort.

* Plan NEW first so a new release lands in the first minutes of the run
  instead of queueing behind refreshes that may all be unbuildable.
* A failed NEW version is recorded in a `missing` output and later reported
  by the final gate; a failed REFRESH stays a warning and keeps its image.
* The final gate now fails only on `missing` or on a current version with no
  image in the registry — a missed upstream release is still visible and red.

Also removes two early aborts that silently skipped the prune and the release
publish whenever the current version failed to build: step 4 no longer exits
after a failed build, and the :latest step records its miss instead of
exiting. Publishing now always happens, so the releases for the versions that
did ship are in place even when the run legitimately ends red.
2026-09-28 14:33:27 -04:00
4 changed files with 136 additions and 25 deletions

No files matched your search

+50 -16
View File
@@ -13,8 +13,16 @@ name: Sync upstream and build images
# 4. Publish one Gitea release per built version on the mirrored upstream # 4. Publish one Gitea release per built version on the mirrored upstream
# tag, titled like upstream, carrying upstream's own notes plus a short # tag, titled like upstream, carrying upstream's own notes plus a short
# image footer. Updated in place when the image is rebuilt. # image footer. Updated in place when the image is rebuilt.
# 5. A version that fails to build or push fails the run: a missed upstream # 5. A version that fails to build or push fails the run, but only when the
# release must be visible, not a warning in a green run. # registry has never had it: a missed upstream release must be visible,
# not a warning in a green run. A *refresh* rebuild — the registry
# already has an image for that version and is only being moved onto a
# newer pnpm/base/recipe — is best-effort: it warns, leaves the existing
# image alone, and cannot turn the run red. Old versions cannot always
# be rebuilt any more, because their floating dependency ranges now
# resolve to packages that have moved past them, and a doomed 0.1.x
# refresh must not hide the 0.2.x release that did ship. Every failure
# stays listed in the run either way.
# #
# Images are pushed with regctl, in 50 MB chunks. The registry sits behind # Images are pushed with regctl, in 50 MB chunks. The registry sits behind
# Cloudflare, which rejects any request body over 100 MB; `docker push` sends a # Cloudflare, which rejects any request body over 100 MB; `docker push` sends a
@@ -143,23 +151,35 @@ jobs:
done < <(sort -V /tmp/upstream-versions) done < <(sort -V /tmp/upstream-versions)
echo "candidates:$CANDIDATES" echo "candidates:$CANDIDATES"
BUILDS="" # Two kinds of build, and they do not matter equally. NEW is a
# version the registry has never had — an upstream release that has
# not shipped here yet, which is the whole point of this job.
# REFRESH is an already-published version being moved onto newer
# toolchain. Plan NEW first, so a new release lands in the first
# minutes of the run instead of queueing behind refreshes that may
# all be unbuildable.
NEW=""
REFRESH=""
for V in $CANDIDATES; do for V in $CANDIDATES; do
if ! tag_exists "$V"; then if ! tag_exists "$V"; then
echo "$V: missing -> build"; BUILDS="$BUILDS $V" echo "$V: missing -> build"; NEW="$NEW $V"
elif forced "$V"; then elif forced "$V"; then
echo "$V: rebuild requested"; BUILDS="$BUILDS $V" echo "$V: rebuild requested"; REFRESH="$REFRESH $V"
else else
HAVE=$(tag_toolchain "$V") HAVE=$(tag_toolchain "$V")
if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; BUILDS="$BUILDS $V"; fi if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; REFRESH="$REFRESH $V"; fi
fi fi
done done
BUILDS="$NEW $REFRESH"
# Newest release candidate = what :latest points at. # Newest release candidate = what :latest points at.
CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1) CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1)
echo "new (must ship):$NEW"
echo "refresh (best-effort):$REFRESH"
echo "planned builds:$BUILDS" echo "planned builds:$BUILDS"
echo "current (:latest): $CURRENT" echo "current (:latest): $CURRENT"
{ {
echo "builds=${BUILDS}" echo "builds=${BUILDS}"
echo "new=${NEW}"
echo "current=${CURRENT}" echo "current=${CURRENT}"
echo "pnpm=${PNPM_LATEST}" echo "pnpm=${PNPM_LATEST}"
echo "base_digest=${BASE_DIGEST}" echo "base_digest=${BASE_DIGEST}"
@@ -174,7 +194,6 @@ jobs:
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }} PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }} BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }} RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }}
CURRENT: ${{ steps.plan.outputs.current }}
CI_RUN: ${{ github.run_number }} CI_RUN: ${{ github.run_number }}
run: | run: |
set -u set -u
@@ -262,6 +281,7 @@ jobs:
) )
FAILED="" FAILED=""
MISSING=""
for V in ${{ steps.plan.outputs.builds }}; do for V in ${{ steps.plan.outputs.builds }}; do
if build_one "$V"; then if build_one "$V"; then
echo "::notice::built ${IMAGE}:${V}" echo "::notice::built ${IMAGE}:${V}"
@@ -269,17 +289,24 @@ jobs:
docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true
echo "::warning::${V} did not build or push; any existing image for it is left untouched" echo "::warning::${V} did not build or push; any existing image for it is left untouched"
FAILED="$FAILED $V" FAILED="$FAILED $V"
# Missing from the registry AND failed to build = an upstream
# release we failed to ship. Record it for the final gate.
case " ${{ steps.plan.outputs.new }} " in *" $V "*) MISSING="$MISSING $V";; esac
fi fi
done done
echo "failed=${FAILED}" >> "$GITHUB_OUTPUT" echo "failed=${FAILED}" >> "$GITHUB_OUTPUT"
if [ -n "$FAILED" ]; then echo "missing=${MISSING}" >> "$GITHUB_OUTPUT"
echo "failed versions:$FAILED" # Deliberately no exit here. Steps 5-7 still have to run so the
case " $FAILED " in *" $CURRENT "*) echo "::error::the current version (${CURRENT}) failed to build"; exit 1;; esac # releases for every version that did ship are in place even when
fi # this run ends red; the single red/green decision belongs to the
# last step. Aborting here is what used to leave a new release
# unpublished.
[ -z "$FAILED" ] || echo "failed versions:$FAILED"
# `:latest` = the newest release-candidate version. A manifest copy # `:latest` = the newest release-candidate version. A manifest copy
# through the registry API: identical digest, nothing rebuilt. # through the registry API: identical digest, nothing rebuilt.
- name: Point :latest at the current version - name: Point :latest at the current version
id: latest
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
@@ -290,7 +317,9 @@ jobs:
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json' ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; } digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; }
WANT=$(digest_of "$CURRENT") WANT=$(digest_of "$CURRENT")
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry"; exit 1; } # Do not exit non-zero here either: that would skip both the prune
# and the release publish. Record the miss, let the gate decide.
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry, so :latest cannot be moved"; echo "latest_failed=1" >> "$GITHUB_OUTPUT"; exit 0; }
HAVE=$(digest_of latest || true) HAVE=$(digest_of latest || true)
echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}" echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}"
[ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; } [ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; }
@@ -349,9 +378,14 @@ jobs:
"$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js "$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js
# Last, so :latest and the releases for everything that did ship are # Last, so :latest and the releases for everything that did ship are
# already in place when the run turns red. # already in place before the run turns red — and the only thing that
- name: Fail the run if a version did not ship # turns it red is a version this registry has never published. Refreshes
if: steps.build.outputs.failed != '' # of versions we already ship stay warnings.
- name: Fail the run if a new version did not ship
if: steps.build.outputs.missing != '' || steps.latest.outputs.latest_failed != ''
run: | run: |
echo "::error::these versions did not build or push:${{ steps.build.outputs.failed }}" [ -z "${{ steps.build.outputs.missing }}" ] || \
echo "::error::never-published versions did not build or push:${{ steps.build.outputs.missing }}"
[ "${{ steps.latest.outputs.latest_failed }}" != "1" ] || \
echo "::error::${{ steps.plan.outputs.current }} has no image in the registry, so :latest was not moved"
exit 1 exit 1
+7 -3
View File
@@ -6,9 +6,13 @@
# image installs that exact pinned release from registry.npmjs.org, so its # image installs that exact pinned release from registry.npmjs.org, so its
# provenance chain is: upstream tag -> upstream npm release -> this Dockerfile. # provenance chain is: upstream tag -> upstream npm release -> this Dockerfile.
# #
# No upstream code is patched. The all-interfaces bind goes through upstream's # No upstream code is patched. The bind address comes from the `--patch`
# supported `--patch` launcher layer (/opt/deepseek-harness/web.cordis.patch.yml); # launcher layer (/opt/deepseek-harness/web.cordis.patch.yml), which the
# the UI is only reachable through ports you explicitly publish on the host. # entrypoint renders at startup: dsh 0.2.1-alpha.2+ rejects the unspecified
# address in the webserver schema and gets the container's own IPv4 instead,
# while releases whose schema still only accepts 127.0.0.1 or 0.0.0.0 keep
# 0.0.0.0. The UI is reachable only through ports you explicitly publish on the
# host.
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
Regular → Executable
+65
View File
@@ -38,6 +38,71 @@ else
-*) set -- web --patch "$PATCH" --no-open "$@" ;; -*) set -- web --patch "$PATCH" --no-open "$@" ;;
esac esac
fi fi
# 0.2.1-alpha.2 replaced the webserver's old `host: "127.0.0.1" | "0.0.0.0"`
# union with a config transform that rejects the unspecified address outright —
# "host … is an unspecified (wildcard) address, which is not supported … bind
# one concrete IPv4 or IPv6 address of a local interface instead" — and a
# schema-level check cannot be opted out of from the launcher. The shipped patch
# is therefore a template: render it onto this container's own address and hand
# dsh the rendered copy, so a port published on the host still reaches the UI
# while the socket binds one concrete interface instead of every interface.
# Releases whose schema still accepts the wildcard keep 0.0.0.0, so old images
# rebuild exactly as before. DSH_WEB_HOST overrides the address.
if [ -r "$PATCH" ]; then
BIND_HOST="${DSH_WEB_HOST:-}"
if [ -z "$BIND_HOST" ]; then
# Detect the installed webserver schema rather than guessing from a
# version string.
dsh_root="${DSH_INSTALL_ROOT:-/usr/local/lib/node_modules/@deepseek-ai/dsh}"
ws="$dsh_root/node_modules/@deepseek-ai/dsh-host-webserver/lib/index.js"
if [ -f "$ws" ] && grep -qs 'unspecified (wildcard) address' "$ws"; then
wildcard_rejected=1
elif [ ! -f "$ws" ] && grep -rqs 'unspecified (wildcard) address' "$dsh_root" 2>/dev/null; then
wildcard_rejected=1
else
wildcard_rejected=0
fi
if [ "$wildcard_rejected" = 1 ]; then
# Same discovery dsh's own boot smoke test uses: first non-internal IPv4.
BIND_HOST="$(node -e 'const os=require("os");const n=Object.values(os.networkInterfaces()).flat().find(i=>i&&i.family==="IPv4"&&!i.internal);process.stdout.write(n?n.address:"")' 2>/dev/null || true)"
[ -n "$BIND_HOST" ] || BIND_HOST=127.0.0.1
else
BIND_HOST=0.0.0.0
fi
fi
render_patch() {
sed -e "s|__DSH_BIND_HOST__|${BIND_HOST}|g" \
-e "s|^\([[:space:]]*host:[[:space:]]*\)0\.0\.0\.0[[:space:]]*$|\1${BIND_HOST}|" "$PATCH"
}
mkdir -p "$STATE" 2>/dev/null || true
RUNTIME_PATCH="$STATE/web.cordis.patch.yml"
if ! render_patch > "$RUNTIME_PATCH" 2>/dev/null; then
RUNTIME_PATCH=/tmp/web.cordis.patch.yml
render_patch > "$RUNTIME_PATCH"
fi
chmod 0644 "$RUNTIME_PATCH" 2>/dev/null || true
log "webserver binds ${BIND_HOST}; runtime patch: ${RUNTIME_PATCH}"
# Point every --patch argument that names the shipped template at the
# rendered copy. Rebuilt with a rotating `set --`, so quoting survives.
expect=0
left=$#
while [ "$left" -gt 0 ]; do
arg="$1"
shift
if [ "$expect" = 1 ]; then
if [ "$arg" = "$PATCH" ]; then arg="$RUNTIME_PATCH"; fi
expect=0
elif [ "$arg" = "--patch" ]; then
expect=1
fi
set -- "$@" "$arg"
left=$((left - 1))
done
fi
# True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops # True when anything under $1 is not owned by PUID:PGID. `-print -quit` stops
# at the first hit, so the common "already correct" case costs one stat. # at the first hit, so the common "already correct" case costs one stat.
+14 -6
View File
@@ -1,10 +1,18 @@
# Launcher patch layer for containerized `dsh web` (passed via --patch). # Launcher patch layer for containerized `dsh web` (passed via --patch).
# Upstream's CLI refuses `--host 0.0.0.0` (deliberate friction: the web UI can #
# execute code), but the webserver plugin's schema supports exactly # The host is a placeholder, not a value. From 0.2.1-alpha.2 the webserver
# '127.0.0.1' | '0.0.0.0'; this file is the supported opt-in. Inside a # schema rejects the unspecified address outright ("host … is an unspecified
# container an all-interfaces bind is only reachable through ports you # (wildcard) address, which is not supported … bind one concrete IPv4 or IPv6
# explicitly publish on the Docker host. # address of a local interface instead") and that check is a config transform,
# so no launcher flag can opt out of it; a static 0.0.0.0 — which the earlier
# `"127.0.0.1" | "0.0.0.0"` schema accepted — now fails the boot smoke test.
#
# The image entrypoint renders this file at startup: onto the container's own
# IPv4 address when the installed schema rejects the wildcard, otherwise back
# to 0.0.0.0 so older images build unchanged. A port published on the Docker
# host still reaches the UI either way, because the published port is forwarded
# to exactly that address. Set DSH_WEB_HOST to pin a different address.
- id: webserver - id: webserver
config: config:
host: 0.0.0.0 host: __DSH_BIND_HOST__
port: 3080 port: 3080