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 @@

- pinned - newest - tags + latest + tags ci base license @@ -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`. |