Track the newest release candidate: :latest alias, current-version freshness, on-demand revisions
Sync upstream and build images / sync-and-build (push) Successful in 2m4s

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 <version>[-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 <[email protected]>
This commit is contained in:
Miles WardandClaude Fable 5 committed 2026-09-11 12:35:40 -04:00
1 parent 19e87258ce
commit 76112eadf6
6 files changed
+176 -119

No files matched your search

+4 -12
View File
@@ -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 <host> [--trusted-host <host2>]`',
'- 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);
}
+3 -3
View File
@@ -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'
+98 -46
View File
@@ -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 <version> 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 <version>-r<run> 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<tag> 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 <version>-r<run>. 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 <version>[-rN] tag is immutable, so
# pinning one is always available to anyone who wants a fixed deployment.
# 5. Publishes a v<tag> 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 <version>-r<run> (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<run> 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<tag> 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<tag> 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
+2 -2
View File
@@ -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")
+7 -7
View File
@@ -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" \
+62 -49
View File
@@ -14,9 +14,8 @@
</p>
<p align="center">
<img alt="pinned" src="https://img.shields.io/badge/deployed-0.1.1--rc.2-4d6bfe" />
<img alt="newest" src="https://img.shields.io/gitea/v/release/mward4/deepseek-harness?gitea_url=https%3A%2F%2Fgitea.milesward.dev&include_prereleases&sort=semver&label=newest%20build&color=2874d7" />
<img alt="tags" src="https://img.shields.io/badge/tags-immutable%20%C2%B7%20no%20%3Alatest-59824f" />
<img alt="latest" src="https://img.shields.io/gitea/v/release/mward4/deepseek-harness?gitea_url=https%3A%2F%2Fgitea.milesward.dev&include_prereleases&sort=semver&label=%3Alatest&color=4d6bfe" />
<img alt="tags" src="https://img.shields.io/badge/version%20tags-immutable-59824f" />
<img alt="ci" src="https://gitea.milesward.dev/mward4/deepseek-harness/actions/workflows/sync-upstream.yml/badge.svg?branch=main" />
<img alt="base" src="https://img.shields.io/badge/base-node%2022%20%C2%B7%20bookworm--slim-339933?logo=node.js&logoColor=white" />
<img alt="license" src="https://img.shields.io/badge/license-MIT-330f1f" />
@@ -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 `<version>[-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@<version>` from
`registry.npmjs.org`, nothing else. The version is a build argument with a known-good
default.
- **Immutable tags.** `<version>` for each upstream release, `<version>-r<N>` for a
toolchain refresh of the same release. A tag's digest never changes.
- **Immutable version tags, one moving alias.** `<version>` for each upstream release,
`<version>-r<N>` 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<N>` revision — so an image-level fix reaches the deployment without waiting for an
upstream release.
`-r<N>` 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:
<?xml version="1.0"?>
<Container version="2">
<Name>DeepSeek-Harness</Name>
<Repository>gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2-r2</Repository>
<Repository>gitea.milesward.dev/mward4/deepseek-harness:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/deepseek-harness</Registry>
<Network>bridge</Network>
<Shell>sh</Shell>
@@ -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<N>` 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<N>` 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 <container> 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 `<version>-r<run>` — the path by which a security patch *or* an
image fix reaches the pinned deployment.
4. **Releases** — every image tag without a `v<tag>` 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
`<version>-r<run>` — 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<tag>` 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`. |