From 76112eadf61b9044c89eb8c38e08612b3db9818d Mon Sep 17 00:00:00 2001
From: Miles Ward
Date: Fri, 11 Sep 2026 12:35:40 -0400
Subject: [PATCH] Track the newest release candidate: :latest alias,
current-version freshness, on-demand revisions
Operating model change, at the operator's request: the deployment follows
the newest release candidate rather than a hand-pinned version.
- :latest is now published, as a manifest copy (identical digest) of the
newest build of the newest RC version. It is the ONLY tag that moves;
every [-rN] tag stays immutable for anyone who wants to pin.
- Freshness (pnpm / base image / recipe hash) now applies to the current
version derived from the registry, not to a Dockerfile constant, so an
image fix reaches the deployed line automatically. The `revise` dispatch
input rebuilds any older versions with the current recipe on demand.
- Smoke test now runs on a fresh named volume (root-owned, like a new Unraid
appdata dir) and asserts pid 1 is uid 1000 - the ownership fix is exercised
on every build, not just locally.
- Releases: `latest` gets no release; the "pinned deployment" callout is gone
(the README and the alias are the source of truth now).
- 0.1.1-rc.2 is deprecated; its -r2/-r49/-r52 revisions were removed
(images, tags, releases). The original tag remains as history.
- README/template: :latest in the Repository field, auto-update warning,
versions table and upgrade procedure rewritten for the new model.
Forensic workflow's trusted default -> :latest. inspect-deployment default
container name -> Deepseek.
Co-Authored-By: Claude Fable 5
---
.gitea/scripts/publish-releases.js | 16 +--
.gitea/workflows/inspect-deployment.yml | 6 +-
.gitea/workflows/sync-upstream.yml | 144 +++++++++++++-------
.gitea/workflows/validate-suspect-image.yml | 4 +-
Dockerfile | 14 +-
README.md | 111 ++++++++-------
6 files changed, 176 insertions(+), 119 deletions(-)
diff --git a/.gitea/scripts/publish-releases.js b/.gitea/scripts/publish-releases.js
index 41e799b3be..bc42c96b7b 100644
--- a/.gitea/scripts/publish-releases.js
+++ b/.gitea/scripts/publish-releases.js
@@ -28,8 +28,6 @@ const GITEA_TOKEN = env('GITEA_TOKEN');
const REG_TOKEN = env('REG_TOKEN');
const MAIN_SHA = env('MAIN_SHA');
const RUN_NUMBER = env('RUN_NUMBER', '');
-// The Dockerfile's default DSH_VERSION — the version the deployment is pinned to.
-const PINNED = env('PINNED', '');
const DRY_RUN = process.env.DRY_RUN === '1';
const REGISTRY = new URL(GITEA).host;
const UPSTREAM = 'deepseek-ai/deepseek-harness';
@@ -176,25 +174,18 @@ async function imageFacts(tag) {
function renderBody({ tag, ver, facts, builtFrom, notes }) {
const pull = `gitea.milesward.dev/${IMAGE}:${tag}`;
- const pinned = PINNED !== '' && ver === PINNED;
// A revision says only what is certain: same upstream release, different
// image build. Which input moved (pnpm, the base image, or the container's
// own tooling) is visible in the labels and the commit this release links to.
- let intro = isRevision(tag)
+ const intro = isRevision(tag)
? `Image revision of **DeepSeek Harness (dsh) ${ver}** — the same upstream release, rebuilt from an updated image recipe (a patched base image, a newer pnpm, or a fix to the container's own tooling). Safe to move to from any other build of ${ver}.`
: `Container build of **DeepSeek Harness (dsh) ${ver}** from the official npm release, built and smoke-tested by CI.`;
- if (pinned) {
- intro += isRevision(tag)
- ? ' This is a refresh of the **pinned deployment version**.'
- : ' **This is the pinned deployment version** — the Dockerfile default and the tag the Unraid template runs. Newer versions are published as their own tags and adopted only by editing the template.';
- }
const lines = [
intro, '',
'```', `docker pull ${pull}`, '```', '',
"### What's inside", '',
'| | |', '|---|---|',
- ...(pinned ? ['| role | **Pinned deployment version** |'] : []),
`| dsh | \`${ver}\` — [upstream release notes](https://github.com/${UPSTREAM}/releases/tag/dsh-v${ver}) |`,
`| pnpm | \`${facts.pnpm || 'unknown'}\` |`,
`| base image | \`${facts.base || 'unknown'}\` |`,
@@ -222,7 +213,7 @@ function renderBody({ tag, ver, facts, builtFrom, notes }) {
'- **Post Arguments** (replace the image command entirely): `web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host [--trusted-host ]`',
'- First run: `chown -R 1000:1000 /mnt/user/appdata/deepseek-harness`',
'',
- `Image tags are immutable — this release will never be rebuilt or moved. See the [README](${GITEA}/${REPO}#versions-and-upgrades) for the upgrade procedure.`,
+ `This tag is immutable — it will never be rebuilt or moved. \`:latest\` always points at the newest build of the newest release candidate; pin a version tag if you want a fixed deployment. See the [README](${GITEA}/${REPO}#versions-and-upgrades) for the upgrade procedure.`,
);
return lines.join('\n');
}
@@ -239,8 +230,9 @@ async function registryTags() {
const name = IMAGE.split('/')[1];
const { status, json } = await gitea('GET', `/packages/${OWNER}?type=container&q=${name}&limit=100`);
if (status !== 200 || !Array.isArray(json)) throw new Error(`package list: HTTP ${status}`);
+ // Version-shaped tags only: digests and the moving `latest` alias get no release.
return json
- .filter((p) => p.name === name && !p.version.startsWith('sha256:'))
+ .filter((p) => p.name === name && /^\d/.test(p.version))
.map((p) => p.version)
.sort(compareVersions);
}
diff --git a/.gitea/workflows/inspect-deployment.yml b/.gitea/workflows/inspect-deployment.yml
index 7e506d6962..bad90e17a7 100644
--- a/.gitea/workflows/inspect-deployment.yml
+++ b/.gitea/workflows/inspect-deployment.yml
@@ -11,7 +11,7 @@ on:
inputs:
container:
description: Container name to inspect
- default: DeepSeek-Harness
+ default: Deepseek
jobs:
inspect:
@@ -20,7 +20,7 @@ jobs:
- name: Locate container
run: |
set -eu
- C="${{ inputs.container || 'DeepSeek-Harness' }}"
+ C="${{ inputs.container || 'Deepseek' }}"
if ! docker inspect "$C" > /dev/null 2>&1; then
echo "::error::no container named $C on this host"
echo "== containers on this host whose image mentions deepseek =="
@@ -43,6 +43,6 @@ jobs:
- name: Probe tooling inside the running container
run: |
set -eu
- C="${{ inputs.container || 'DeepSeek-Harness' }}"
+ C="${{ inputs.container || 'Deepseek' }}"
echo "== inside $C =="
docker exec "$C" sh -c 'echo "uid=$(id -u) HOME=$HOME PATH=$PATH"; echo "dsh: $(command -v dsh || echo MISSING) $(dsh --version 2>/dev/null)"; echo "pnpm: $(command -v pnpm || echo MISSING) $(pnpm --version 2>/dev/null)"; echo "node: $(node --version)"; ls -la /usr/local/bin | grep -E "dsh|pnpm" || true'
diff --git a/.gitea/workflows/sync-upstream.yml b/.gitea/workflows/sync-upstream.yml
index bb539c3a12..6ee63b057e 100644
--- a/.gitea/workflows/sync-upstream.yml
+++ b/.gitea/workflows/sync-upstream.yml
@@ -2,23 +2,22 @@ name: Sync upstream and build images
# Keeps this repository current with the official upstream
# (https://github.com/deepseek-ai/deepseek-harness) and the registry stocked
-# with IMMUTABLE version tags:
+# with immutable version tags plus one moving convenience tag:
# 1. Mirrors upstream master into this repo's master branch.
# 2. Builds a new tag for each @deepseek-ai/dsh release that npm
# publishes as `latest` or `next` (plus any version named in a manual
# dispatch) and the registry lacks.
-# 3. Freshness check for the PINNED default version (ARG DSH_VERSION in the
-# Dockerfile): if a newer pnpm release or a patched node base image has
-# shipped, or the build recipe itself changed (Dockerfile, entrypoint,
-# dsh wrapper, bind patch), builds a -r revision tag. This
-# is how an image-level fix reaches the pinned deployment without waiting
-# for the next upstream dsh release.
-# 4. Publishes a v git tag + Gitea release (digest, toolchain, upstream
-# highlights, deployment caveats) for every image tag without one.
-#
-# This workflow NEVER pushes or moves `latest`, and never re-pushes an
-# existing tag: every upgrade of the running deployment is a deliberate
-# template edit to a new immutable tag.
+# 3. Freshness check for the CURRENT version (the newest release-candidate
+# build in the registry): if a newer pnpm, a patched node base image, or
+# a changed build recipe (Dockerfile, entrypoint, wrapper, bind patch)
+# has appeared since its newest build, builds -r. Older
+# versions can be rebuilt with the current recipe on demand via the
+# `revise` input.
+# 4. Points `:latest` at the newest build of the current version. This is
+# the ONLY tag that ever moves; every [-rN] tag is immutable, so
+# pinning one is always available to anyone who wants a fixed deployment.
+# 5. Publishes a v git tag + Gitea release (digest, toolchain, upstream
+# highlights, deployment caveats) for every version tag without one.
on:
schedule:
@@ -29,6 +28,10 @@ on:
description: Build this exact dsh version too (e.g. 0.1.5-alpha.2); latest/next are always considered
required: false
default: ''
+ revise:
+ description: Space-separated existing versions to rebuild with the current recipe as -r (e.g. "0.1.2-rc.1 0.1.5-rc.1")
+ required: false
+ default: ''
push:
branches: [main]
paths:
@@ -66,35 +69,40 @@ jobs:
git remote add mirror "https://oauth2:${TOKEN}@${REGISTRY}/${IMAGE}.git"
git push mirror '+refs/heads/master:refs/heads/master'
- - name: Plan builds (new dsh versions + pinned-version freshness)
+ - name: Plan builds (new versions, current-version freshness, requested revisions)
id: plan
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+ OWNER: ${{ github.repository_owner }}
REQUESTED: ${{ inputs.version }}
+ REVISE: ${{ inputs.revise }}
+ RUN_NUMBER: ${{ github.run_number }}
run: |
set -eu
DSH_LATEST=$(npm view @deepseek-ai/dsh dist-tags.latest)
DSH_NEXT=$(npm view @deepseek-ai/dsh dist-tags.next || true)
PNPM_LATEST=$(npm view pnpm dist-tags.latest)
- PINNED=$(sed -n 's/^ARG DSH_VERSION=//p' Dockerfile | head -1)
docker pull -q "$BASE_IMAGE" > /dev/null
BASE_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$BASE_IMAGE")
RECIPE_HASH=$(cat Dockerfile docker-entrypoint.sh run-as-runtime-user.sh web.cordis.patch.yml | sha256sum | cut -c1-12)
- echo "dsh latest=$DSH_LATEST next=$DSH_NEXT requested='$REQUESTED' pinned=$PINNED pnpm=$PNPM_LATEST base=$BASE_DIGEST recipe=$RECIPE_HASH"
+ echo "dsh latest=$DSH_LATEST next=$DSH_NEXT requested='$REQUESTED' revise='$REVISE' pnpm=$PNPM_LATEST base=$BASE_DIGEST recipe=$RECIPE_HASH"
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'
tag_exists() {
- [ "$(curl -s -o /dev/null -w '%{http_code}' -u "${{ github.repository_owner }}:${TOKEN}" \
+ [ "$(curl -s -o /dev/null -w '%{http_code}' -u "${OWNER}:${TOKEN}" \
-H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1")" = 200 ]
}
+ # Every version-shaped tag in the registry (no digests, no `latest`).
+ registry_tags() {
+ curl -s -H "Authorization: token ${TOKEN}" \
+ "https://${REGISTRY}/api/v1/packages/${OWNER}?type=container&q=deepseek-harness&limit=100" \
+ | tr ',' '\n' | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | grep -E '^[0-9]' || true
+ }
# Provenance labels from a tag's image config ("pnpm|base|recipe").
# Empty output means "treat as stale". A multi-arch tag resolves to an
- # index, whose first child manifest is followed to reach the config —
- # without that, such a tag would look stale on every run and spam a
- # new revision build every six hours.
+ # index, whose first child manifest is followed to reach the config.
fetch_manifest() {
- curl -s -u "${{ github.repository_owner }}:${TOKEN}" -H "Accept: $ACCEPT" \
- "https://${REGISTRY}/v2/${IMAGE}/manifests/$1"
+ curl -s -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1"
}
first_digest() { printf '%s' "$1" | grep -o '"digest": *"sha256:[a-f0-9]*"' | head -1 | grep -o 'sha256:[a-f0-9]*'; }
tag_toolchain() {
@@ -106,7 +114,7 @@ jobs:
fi
CFG=$(first_digest "$MAN") || return 0
[ -n "$CFG" ] || return 0
- BLOB=$(curl -s -u "${{ github.repository_owner }}:${TOKEN}" "https://${REGISTRY}/v2/${IMAGE}/blobs/$CFG") || return 0
+ BLOB=$(curl -s -u "${OWNER}:${TOKEN}" "https://${REGISTRY}/v2/${IMAGE}/blobs/$CFG") || return 0
P=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.pnpm-version":"[^"]*"' | cut -d'"' -f4 || true)
B=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.base-digest":"[^"]*"' | cut -d'"' -f4 || true)
R=$(printf '%s' "$BLOB" | grep -o '"dev.milesward.recipe-hash":"[^"]*"' | cut -d'"' -f4 || true)
@@ -114,6 +122,8 @@ jobs:
}
BUILDS=""
+ planned() { case " $BUILDS " in *" $1="*) return 0;; *) return 1;; esac; }
+
# 1. Every candidate release the registry lacks -> new version tag.
# Candidates: npm `latest`, npm `next`, and an explicitly requested
# version (alphas are only built on request).
@@ -122,31 +132,41 @@ jobs:
BUILDS="$BUILDS $V=$V"
fi
done
- # 2. Pinned version: build it if missing; otherwise compare the newest
- # existing build's toolchain labels and cut a -rN revision when
- # pnpm or the base image moved.
- case " $BUILDS " in *" $PINNED=$PINNED "*) PINNED_PLANNED=1;; *) PINNED_PLANNED="";; esac
- if [ -z "$PINNED_PLANNED" ] && ! tag_exists "$PINNED"; then
- BUILDS="$BUILDS $PINNED=$PINNED"
- elif [ -z "$PINNED_PLANNED" ]; then
- NEWEST=$(curl -s -H "Authorization: token ${TOKEN}" \
- "https://${REGISTRY}/api/v1/packages/${{ github.repository_owner }}?type=container&q=deepseek-harness&limit=100" \
- | tr ',' '\n' | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' \
- | grep -E "^${PINNED}(-r[0-9]+)?$" | sort -V | tail -1)
+
+ # 2. CURRENT = newest release-candidate version (registry + planned;
+ # alphas never qualify). Its newest build must carry the current
+ # toolchain and recipe, or a -r revision is cut.
+ EXISTING=$(registry_tags)
+ CURRENT=$( { printf '%s\n' $EXISTING; for e in $BUILDS; do printf '%s\n' "${e#*=}"; done; } \
+ | sed 's/-r[0-9]*$//' | grep -v alpha | sort -uV | tail -1)
+ echo "current version: $CURRENT"
+ if [ -n "$CURRENT" ] && ! planned "$CURRENT"; then
+ NEWEST=$(printf '%s\n' $EXISTING | grep -E "^${CURRENT}(-r[0-9]+)?$" | sort -V | tail -1)
TOOL=$(tag_toolchain "$NEWEST")
- echo "newest build of pinned: $NEWEST toolchain='$TOOL'"
+ echo "newest build of current: $NEWEST toolchain='$TOOL'"
if [ "$TOOL" != "${PNPM_LATEST}|${BASE_DIGEST}|${RECIPE_HASH}" ]; then
echo "stale (want ${PNPM_LATEST}|${BASE_DIGEST}|${RECIPE_HASH}) -> revision build"
- BUILDS="$BUILDS ${PINNED}-r${{ github.run_number }}=$PINNED"
+ BUILDS="$BUILDS ${CURRENT}-r${RUN_NUMBER}=$CURRENT"
fi
fi
+ # 3. Requested revisions of other existing versions.
+ for V in $REVISE; do
+ if planned "$V" || planned "${V}-r${RUN_NUMBER}"; then continue; fi
+ if tag_exists "$V"; then
+ BUILDS="$BUILDS ${V}-r${RUN_NUMBER}=$V"
+ else
+ echo "::warning::revise: $V is not in the registry; use the version input to build it first"
+ fi
+ done
+
echo "planned builds: '${BUILDS}'"
{
echo "builds=${BUILDS}"
echo "pnpm=${PNPM_LATEST}"
echo "base_digest=${BASE_DIGEST}"
echo "recipe_hash=${RECIPE_HASH}"
+ echo "current=${CURRENT}"
} >> "$GITHUB_OUTPUT"
- name: Build, smoke-test, and push
@@ -177,14 +197,17 @@ jobs:
-t "$REF" .
# Smoke: versions match, and the web server actually boots with
- # the baked patch and answers HTTP on its non-loopback interface.
- # 200 = pre-token versions; 303/401 = 0.1.2+ launch-token auth.
+ # the baked patch and answers HTTP on its non-loopback interface —
+ # on a ROOT-owned state volume, which is what a fresh Unraid appdata
+ # directory looks like. 200 = pre-token versions; 303/401 = 0.1.2+.
SMOKE="dsh-smoke-${{ github.run_id }}"
docker rm -f "$SMOKE" 2>/dev/null || true
- docker run -d --name "$SMOKE" "$REF"
+ docker volume rm -f "$SMOKE" 2>/dev/null || true
+ docker run -d --name "$SMOKE" --mount "type=volume,src=$SMOKE,dst=/home/node/.dsh" "$REF"
sleep 3
[ "$(docker exec "$SMOKE" dsh --version)" = "$VER" ]
[ "$(docker exec "$SMOKE" pnpm --version)" = "$PNPM_VERSION" ]
+ [ "$(docker exec "$SMOKE" awk '/^Uid:/{print $2}' /proc/1/status)" = "1000" ]
OK=""
for i in $(seq 1 30); do
if docker exec "$SMOKE" node -e '
@@ -200,18 +223,48 @@ jobs:
if [ -z "$OK" ]; then
echo "::error::smoke test failed for $REF (server never answered on the container interface)"
docker logs "$SMOKE" | tail -50
- docker rm -f "$SMOKE"
+ docker rm -f "$SMOKE"; docker volume rm -f "$SMOKE" || true
exit 1
fi
- docker rm -f "$SMOKE"
+ docker rm -f "$SMOKE"; docker volume rm -f "$SMOKE" || true
docker push "$REF"
done
- # Every image tag in the registry gets a matching v git tag + Gitea
+ # `:latest` is the one moving tag: the newest build of the current
+ # (newest release-candidate) version. Implemented as a manifest copy
+ # through the registry API, so the digest is byte-identical to the
+ # version tag it points at and nothing is rebuilt or re-pushed.
+ - name: Point :latest at the newest build of the current version
+ env:
+ TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+ OWNER: ${{ github.repository_owner }}
+ CURRENT: ${{ steps.plan.outputs.current }}
+ run: |
+ set -eu
+ [ -n "$CURRENT" ] || { echo "no current version"; exit 0; }
+ 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'
+ TARGET=$(curl -s -H "Authorization: token ${TOKEN}" \
+ "https://${REGISTRY}/api/v1/packages/${OWNER}?type=container&q=deepseek-harness&limit=100" \
+ | tr ',' '\n' | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' \
+ | grep -E "^${CURRENT}(-r[0-9]+)?$" | sort -V | tail -1)
+ [ -n "$TARGET" ] || { echo "::error::no build of ${CURRENT} in the registry"; exit 1; }
+ 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 "$TARGET")
+ HAVE=$(digest_of latest || true)
+ echo "latest -> $TARGET ($WANT); currently ${HAVE:-unset}"
+ if [ "$WANT" = "$HAVE" ]; then echo "latest already current"; exit 0; fi
+ curl -s -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" -D /tmp/hdr -o /tmp/manifest "https://${REGISTRY}/v2/${IMAGE}/manifests/${TARGET}"
+ CT=$(grep -i '^content-type' /tmp/hdr | awk '{print $2}' | tr -d '\r')
+ CODE=$(curl -s -o /tmp/put.out -w '%{http_code}' -X PUT -u "${OWNER}:${TOKEN}" -H "Content-Type: ${CT}" \
+ --data-binary @/tmp/manifest "https://${REGISTRY}/v2/${IMAGE}/manifests/latest")
+ [ "$CODE" = 201 ] || { echo "::error::retag failed: HTTP $CODE"; cat /tmp/put.out; exit 1; }
+ echo "latest now -> $TARGET ($(digest_of latest))"
+
+ # Every version tag in the registry gets a matching v git tag + Gitea
# release with digest, toolchain, upstream highlights and deployment
- # caveats. Idempotent (only tags without a release are published), and it
- # runs on the image base so the script needs nothing from the runner.
+ # caveats (`latest` is an alias and gets none). Idempotent; runs on the
+ # image base so the script needs nothing from the runner.
- name: Publish releases for image tags without one
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -222,8 +275,7 @@ jobs:
run: |
set -eu
MAIN_SHA=$(git rev-parse HEAD)
- PINNED=$(sed -n 's/^ARG DSH_VERSION=//p' Dockerfile | head -1)
docker run --rm -i \
-e "GITEA=https://${REGISTRY}" -e OWNER -e REPO -e IMAGE -e GITEA_TOKEN -e REG_TOKEN \
- -e "MAIN_SHA=${MAIN_SHA}" -e RUN_NUMBER -e "PINNED=${PINNED}" \
+ -e "MAIN_SHA=${MAIN_SHA}" -e RUN_NUMBER \
"$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js
diff --git a/.gitea/workflows/validate-suspect-image.yml b/.gitea/workflows/validate-suspect-image.yml
index 8dceb1cf9c..7c3de5c420 100644
--- a/.gitea/workflows/validate-suspect-image.yml
+++ b/.gitea/workflows/validate-suspect-image.yml
@@ -14,7 +14,7 @@ on:
default: runzhliu/deepseek-harness:0.1.1-rc.2
trusted:
description: Trusted image reference
- default: gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2
+ default: gitea.milesward.dev/mward4/deepseek-harness:latest
jobs:
validate:
@@ -42,7 +42,7 @@ jobs:
run: |
set -eu
SUSPECT="${{ inputs.suspect || 'runzhliu/deepseek-harness:0.1.1-rc.2' }}"
- TRUSTED="${{ inputs.trusted || 'gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2' }}"
+ TRUSTED="${{ inputs.trusted || 'gitea.milesward.dev/mward4/deepseek-harness:latest' }}"
docker pull -q "$TRUSTED"
mkdir -p /tmp/suspect /tmp/trusted
s=$(docker create "$SUSPECT")
diff --git a/Dockerfile b/Dockerfile
index 1aeb35be4e..a525268aa2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,10 +12,10 @@
FROM node:22-bookworm-slim
-# DSH_VERSION default is the pinned, known-good deployment version: 0.1.2+
-# introduced launch-token auth and the @Remote gateway (breaks lanmode).
-# Moving off it is a deliberate template edit, never a side effect of CI.
-ARG DSH_VERSION=0.1.1-rc.2
+# DSH_VERSION default: the current release candidate, for local builds. CI
+# passes every version it builds explicitly and derives "current" from the
+# registry, so this default never steers the pipeline.
+ARG DSH_VERSION=0.1.5-rc.2
# pnpm is required on PATH by dsh's plugin manager (`dsh plugin --profile web
# add|update|remove`). CI passes the current release; the scheduled freshness
# check rebuilds a -rN revision tag when a newer pnpm or base image ships.
@@ -87,9 +87,9 @@ ARG GIT_SHA=""
ARG CI_RUN=""
ARG BUILD_DATE=""
# RECIPE_HASH: digest of the build inputs (this Dockerfile, the entrypoint,
-# the dsh wrapper, the bind patch). The freshness check cuts a -rN revision of
-# the pinned version when it changes, so an image-recipe fix actually reaches
-# the deployment instead of waiting for the next upstream release.
+# the wrapper, the bind patch). The freshness check cuts a -rN revision of the
+# current version when it changes, so an image-recipe fix actually reaches the
+# deployment instead of waiting for the next upstream release.
ARG RECIPE_HASH=""
LABEL org.opencontainers.image.title="deepseek-harness" \
org.opencontainers.image.description="DeepSeek Harness (dsh) web UI, built from the official npm release" \
diff --git a/README.md b/README.md
index c6c74fd865..16d1a2a50f 100644
--- a/README.md
+++ b/README.md
@@ -14,9 +14,8 @@
-
-
-
+
+
@@ -32,11 +31,11 @@ properly: the upstream source is mirrored here, the image installs the exact pin
official release on a slim Node base, and CI re-verifies and republishes on a cadence —
without ever changing what a running deployment executes.
-> **Status.** The deployed version is pinned to **`0.1.1-rc.2`**. Newer upstream
-> releases are built and published automatically as their own tags (currently up to
-> `0.1.5-rc.2`) but nothing moves until the template is edited by hand — see
-> [Versions and upgrades](#versions-and-upgrades) for why, and for what changed in
-> `0.1.2+` before you jump.
+> **Status.** `:latest` tracks the newest release-candidate build (currently
+> **`0.1.5-rc.2`**) and is the tag the Unraid template runs. Every `[-rN]` tag is
+> immutable, so pinning one is always an option. `dsh` is a developer preview: read
+> [Versions and upgrades](#versions-and-upgrades) before applying an update Unraid offers
+> you — `0.1.2+` added launch-token auth and `0.1.5` migrates session logs irreversibly.
## Contents
@@ -64,11 +63,14 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
was never going to be re-audited on every update. Here the provenance is a chain you
can follow: **upstream tag → upstream npm release → this Dockerfile → this registry**,
with the Dockerfile in plain sight and no upstream code modified.
-- **`:latest` silently upgraded a live deployment.** A routine container restart pulled
- a moving tag and jumped from `0.1.1-rc.2` to the `0.1.2` line, which added mandatory
- launch-token auth and swapped the API gateway — breaking remote access and a plugin in
- one go. So this registry publishes **immutable version tags only**. There is no
- `:latest`, and CI is incapable of re-pushing an existing tag.
+- **A moving tag once upgraded a live deployment by surprise.** An update pulled `:latest`
+ and jumped from `0.1.1-rc.2` to the `0.1.2` line, which added mandatory launch-token
+ auth and swapped the API gateway — breaking remote access and a plugin in one go. So
+ every **version tag here is immutable** and CI is incapable of re-pushing one; `:latest`
+ exists as a convenience alias to the newest release-candidate build, and the rule for
+ living with it is simple: **do not enable Unraid auto-update on this container**, and
+ read the release notes before clicking "update". Anyone who wants a fixed deployment
+ pins a version tag instead.
- **Upstream refuses to bind `0.0.0.0` from the CLI**, on purpose (the web UI can execute
code). The image doesn't patch that out; it uses upstream's own supported opt-in — a
`--patch` launcher layer — so the safety intent survives and exposure is decided
@@ -80,17 +82,20 @@ Three things went wrong with the alternatives, and this repo is shaped by each:
- **Official release, pinned exactly.** `npm install -g @deepseek-ai/dsh@` from
`registry.npmjs.org`, nothing else. The version is a build argument with a known-good
default.
-- **Immutable tags.** `` for each upstream release, `-r` for a
- toolchain refresh of the same release. A tag's digest never changes.
+- **Immutable version tags, one moving alias.** `` for each upstream release,
+ `-r` for a rebuild of the same release; a version tag's digest never
+ changes. `:latest` is a manifest-level alias to the newest build of the newest release
+ candidate — same bytes, same digest as the version tag it points at.
- **Smoke-tested before push.** Every build must report the intended `dsh` and `pnpm`
versions *and* boot the web server with the baked patch and answer HTTP on its
non-loopback interface — so a release the patch no longer applies to fails in CI,
loudly, not on your NAS.
-- **Toolchain and recipe freshness.** CI compares the newest build of the pinned version
+- **Toolchain and recipe freshness.** CI compares the newest build of the current version
against the current pnpm release, the current `node:22-bookworm-slim` digest (where
`npm` itself comes from), and a hash of the build recipe; if any moved, it publishes a
- `-r` revision — so an image-level fix reaches the deployment without waiting for an
- upstream release.
+ `-r` revision and moves `:latest` to it — so a security patch or an image-level fix
+ reaches the deployment without waiting for an upstream release. Older versions can be
+ rebuilt with the current recipe on demand.
#### Runtime hygiene
- **Volume ownership repairs itself.** A bind mount arrives with the *host's* ownership,
@@ -128,7 +133,7 @@ mkdir -p dsh/state dsh/workspace
docker run -d --name dsh -p 3080:3080 \
-v "$PWD/dsh/state:/home/node/.dsh" \
-v "$PWD/dsh/workspace:/workspace" \
- gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2-r2 \
+ gitea.milesward.dev/mward4/deepseek-harness:latest \
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host $(hostname)
open http://$(hostname):3080
@@ -154,7 +159,9 @@ Add the container from the template below (Docker tab → Add Container → past
drop it in `/boot/config/plugins/dockerMan/templates-user/`). Two fields are the whole
operating contract:
-- **Repository** is an explicit version tag. Never `:latest` — it doesn't exist here.
+- **Repository** is `:latest` (newest release-candidate build) or an explicit version tag
+ if you want a fixed deployment. Either way, **leave Unraid's auto-update off** for this
+ container: dsh versions can change behaviour, and the release notes tell you what.
- **Post Arguments replace the image command entirely.** Always start with
`web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open`, then add one
`--trusted-host` per name you'll browse from.
@@ -163,7 +170,7 @@ operating contract:
DeepSeek-Harness
- gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2-r2
+ gitea.milesward.dev/mward4/deepseek-harness:latest
https://gitea.milesward.dev/mward4/deepseek-harness
bridge
sh
@@ -183,7 +190,7 @@ Without a matching --trusted-host, non-loopback requests get HTTP 403 on /api -
The Settings/credentials pages are additionally restricted to loopback pages by upstream design; edit them via SSH tunnel (ssh -L 3080:127.0.0.1:3080 root@unraid-ip, then browse http://127.0.0.1:3080). The @goodandready/dsh-lanmode community plugin can lift that restriction on dsh 0.1.1 (it is NOT compatible with 0.1.2+, and it is third-party code that disables an upstream security fence - review it before trusting it with your credentials).
-VERSIONS: this registry publishes immutable version tags only - NO :latest. dsh is a developer preview with breaking changes between releases (0.1.2 adds mandatory launch-token auth: token in docker logs, rotates every restart; 0.1.5 migrates session logs to a format that cannot be downgraded). Upgrade by editing this tag deliberately, after backing up appdata. Tags like 0.1.1-rc.2-rN are the same dsh version rebuilt with a patched toolchain or an image fix - safe to move to within the same dsh version.
+VERSIONS: :latest follows the newest release-candidate build; every version tag (e.g. 0.1.5-rc.2, or 0.1.5-rc.2-rN for a rebuild of the same version) is immutable and can be pinned instead. dsh is a developer preview with breaking changes between releases: 0.1.2 added mandatory launch-token auth (token in docker logs, rotates every restart, exchanged for a 30-day cookie bound to the origin you open it on); 0.1.5 migrates session logs to a format that cannot be downgraded. LEAVE AUTO-UPDATE OFF for this container, back up appdata before applying an update, and read the release notes on the Gitea Releases page first.
PLUGINS: manage them with dsh's own tooling, e.g.
docker exec DeepSeek-Harness dsh plugin --profile web add|update|remove PACKAGE
@@ -239,31 +246,32 @@ Three rules that are easy to get wrong:
## Versions and upgrades
-| Tag | dsh | Role |
+| Tag | dsh | Notes |
|---|---|---|
-| `0.1.1-rc.2-r2` | 0.1.1-rc.2 | **Pinned / deployed.** Adds the self-healing volume ownership fix. |
-| `0.1.1-rc.2` | 0.1.1-rc.2 | Superseded by `-r1`: needs a manual `chown -R 1000:1000` on appdata. |
-| `0.1.2-rc.1` | 0.1.2-rc.1 | First launch-token release; Remote gateway replaces the ApiProxy. |
-| `0.1.5-rc.1` | 0.1.5-rc.1 | Upstream npm `latest`. Session format V3 — **no downgrade** once migrated. |
-| `0.1.5-rc.2` | 0.1.5-rc.2 | Upstream npm `next`. Feedback-dialog and file-card refinements on top of rc.1. |
+| `:latest` | → newest RC build | Moving alias; currently the newest build of `0.1.5-rc.2`. |
+| `0.1.5-rc.2` (+ `-rN`) | 0.1.5-rc.2 | Current. Upstream npm `next`; feedback-dialog and file-card refinements on rc.1. |
+| `0.1.5-rc.1` (+ `-rN`) | 0.1.5-rc.1 | Upstream npm `latest`. Session format V3 — **no downgrade** once migrated. |
+| `0.1.2-rc.1` (+ `-rN`) | 0.1.2-rc.1 | First launch-token release; Remote gateway replaces the ApiProxy. |
+| `0.1.1-rc.2` | 0.1.1-rc.2 | Deprecated. Predates the self-healing entrypoint: needs a manual `chown -R 1000:1000` on appdata. |
The full list, with digests and notes, is on the [Releases](../../releases) page — one
-release per image tag, published by CI.
+release per version tag, published by CI.
-A `-r` suffix means the same dsh version rebuilt with a patched toolchain or an image
-fix, so moving between `X` and `X-rN` is safe and needs no preparation. Changing the dsh
-version is the deliberate part.
+A `-r` suffix means the same dsh version rebuilt from an updated image recipe (patched
+base image, newer pnpm, or a fix to the container's own tooling), so moving between `X` and
+`X-rN` is safe and needs no preparation. Changing the **dsh version** is the deliberate
+part — and with `:latest` in the template, that happens whenever you accept an update
+Unraid offers. Before you do:
-`dsh` is a developer preview and upstream says so: expect breaking changes between
-releases. A **version** upgrade here is always three deliberate steps:
-
-1. **Back up the state volume** (`/mnt/user/appdata/deepseek-harness`). From `0.1.5`
+1. **Read the release** for the version you're about to land on — every one lists what
+ changed upstream and what it means for this deployment.
+2. **Back up the state volume** (`/mnt/user/appdata/deepseek-harness`). From `0.1.5`
upstream migrates session logs to a new format that cannot be read by older versions.
-2. **Reconcile plugins first.** `@goodandready/dsh-lanmode` ≤ 0.6.8 breaks the web client
+3. **Reconcile plugins first.** `@goodandready/dsh-lanmode` ≤ 0.6.8 breaks the web client
on `0.1.2+` (`web boot: … waiting for services: remote.*`). Remove or update it *before*
- changing the tag:
- `docker exec DeepSeek-Harness dsh plugin --profile web remove @goodandready/dsh-lanmode`
-3. **Edit the Repository tag** in the template and apply.
+ updating:
+ `docker exec dsh plugin --profile web remove @goodandready/dsh-lanmode`
+4. **Apply the update** (or edit the Repository tag, if you pin).
### What changes in `0.1.2+`: launch-token auth
@@ -334,20 +342,25 @@ runners every six hours (`17 */6 * * *`) and on any change to the build inputs:
2. **New releases** — for each `@deepseek-ai/dsh` version npm publishes as `latest` or
`next` (release candidates; alphas only when asked for) that has no tag in the
registry: build, smoke-test, push. A manual run can name any exact version.
-3. **Freshness** — reads the labels off the newest build of the pinned version and
- compares them to the current pnpm release, the current `node:22-bookworm-slim` digest,
- and a hash of the build recipe (Dockerfile, entrypoint, `dsh` wrapper, bind patch). If
- any moved, publishes `-r` — the path by which a security patch *or* an
- image fix reaches the pinned deployment.
-4. **Releases** — every image tag without a `v` release gets one, tagged at the
+3. **Freshness** — reads the labels off the newest build of the *current* version (the
+ newest release candidate in the registry) and compares them to the current pnpm
+ release, the current `node:22-bookworm-slim` digest, and a hash of the build recipe
+ (Dockerfile, entrypoint, wrapper, bind patch). If any moved, publishes
+ `-r` — the path by which a security patch *or* an image fix reaches the
+ deployment. A manual run's `revise` input rebuilds any older versions the same way.
+4. **`:latest`** — re-pointed at the newest build of the current version whenever that
+ changes, as a manifest copy through the registry API (identical digest, nothing
+ rebuilt). It is the only tag that ever moves.
+5. **Releases** — every version tag without a `v` release gets one, tagged at the
`main` commit recorded in the image's `org.opencontainers.image.revision` label, with
digest and toolchain, the upstream release's English notes, and the caveats that apply
to that version (launch token from `0.1.2`, session-format migration from `0.1.5`).
Generated by [`publish-releases.js`](.gitea/scripts/publish-releases.js), which runs
on the image's own Node base so the runner needs nothing installed.
-Nothing in this pipeline can change an existing tag or publish `latest`. Adopting any
-new build — release or revision — is a template edit.
+Nothing in this pipeline can change an existing version tag. Whether a new build reaches
+your deployment is decided at the Unraid update button — which is why auto-update stays
+off.
## Validating a third-party image
@@ -368,7 +381,7 @@ the official release, upstream safety check intact, no tampering.
|---|---|
| `master` | Exact mirror of upstream `master`. Never edited here. |
| `main` | This branch — the build. |
-| `Dockerfile` | The image. `ARG DSH_VERSION` is the pinned default. |
+| `Dockerfile` | The image. `ARG DSH_VERSION` is the default for local builds; CI passes every version explicitly. |
| `docker-entrypoint.sh` | Ownership repair + privilege drop, then `exec dsh "$@"`. |
| `run-as-runtime-user.sh` | Installed over `dsh`, `pnpm`, `pnpx`, `pn`, `pnx`; re-execs root invocations as the runtime user. |
| `web.cordis.patch.yml` | The `0.0.0.0` bind patch, applied via `--patch`. |