Files
deepseek-harness/.gitea/workflows/sync-upstream.yml
T
Miles WardandClaude Fable 5 a30d19c143
Sync upstream and build images / sync-and-build (push) Successful in 30s
Skip upstream versions that cannot be built, with the reason recorded
0.1.3-alpha.2 depends on the native module fs-ext, which needs a C toolchain
the slim runtime image does not carry; without a skip list the run would
retry it every six hours and warn each time. README notes the three
upstream tags that have no image and why.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-09-11 13:25:13 -04:00

259 lines
13 KiB
YAML

name: Sync upstream and build images
# Mirrors the official DeepSeek Harness repository and ships one container
# image per upstream release, with this repo's fixes applied:
# 1. Mirror upstream `master` and every `dsh-v*` tag — same names, same
# commits, same dates as https://github.com/deepseek-ai/deepseek-harness/tags.
# 2. For every upstream version that is also published on npm (from
# MIN_VERSION up): build the image if the registry lacks it, or rebuild it
# in place when our recipe (Dockerfile, entrypoint, wrapper, bind patch),
# pnpm, or the node base image changed since it was built. One image tag
# per version — <version>, nothing else.
# 3. Point `:latest` at the newest release-candidate version.
# 4. Publish one Gitea release per built version on the mirrored upstream
# tag, titled like upstream, carrying upstream's own notes plus a short
# image footer. Updated in place when the image is rebuilt.
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
inputs:
rebuild:
description: Force-rebuild these versions (space-separated) or "all"
required: false
default: ''
push:
branches: [main]
paths:
- Dockerfile
- docker-entrypoint.sh
- run-as-runtime-user.sh
- web.cordis.patch.yml
- .gitea/workflows/sync-upstream.yml
- .gitea/scripts/publish-releases.js
env:
UPSTREAM: https://github.com/deepseek-ai/deepseek-harness.git
REGISTRY: gitea.milesward.dev
IMAGE: mward4/deepseek-harness
BASE_IMAGE: node:22-bookworm-slim
# Oldest upstream version this project ships an image for.
MIN_VERSION: 0.1.1-rc.2
# Upstream versions that cannot be built from npm on this base image, with
# the reason, so the run neither retries them every six hours nor warns:
# 0.1.3-alpha.2 — depends on the native module fs-ext, which needs a C
# toolchain (node-gyp) the slim runtime image lacks.
SKIP_VERSIONS: 0.1.3-alpha.2
jobs:
sync-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout CI branch
uses: actions/checkout@v4
with:
ref: main
- name: Mirror upstream master and dsh-v* tags
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eux
git clone --bare --single-branch --branch master "$UPSTREAM" /tmp/upstream
cd /tmp/upstream
git fetch -q origin '+refs/tags/dsh-v*:refs/tags/dsh-v*'
git remote add mirror "https://oauth2:${TOKEN}@${REGISTRY}/${IMAGE}.git"
git push mirror '+refs/heads/master:refs/heads/master' '+refs/tags/dsh-v*:refs/tags/dsh-v*'
git tag -l 'dsh-v*' | sed 's/^dsh-v//' > /tmp/upstream-versions
echo "upstream versions: $(tr '\n' ' ' < /tmp/upstream-versions)"
- name: Plan builds
id: plan
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
OWNER: ${{ github.repository_owner }}
REBUILD: ${{ inputs.rebuild }}
run: |
set -eu
PNPM_LATEST=$(npm view pnpm dist-tags.latest)
npm view @deepseek-ai/dsh versions --json | grep -o '"[0-9][^"]*"' | tr -d '"' > /tmp/npm-versions
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)
WANT="${PNPM_LATEST}|${BASE_DIGEST}|${RECIPE_HASH}"
echo "pnpm=$PNPM_LATEST base=$BASE_DIGEST recipe=$RECIPE_HASH rebuild='$REBUILD'"
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 "${OWNER}:${TOKEN}" \
-H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1")" = 200 ]
}
fetch_manifest() { 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]*'; }
# Provenance labels of an image ("pnpm|base|recipe"); follows a
# multi-arch index to its first platform manifest.
tag_toolchain() {
MAN=$(fetch_manifest "$1") || return 0
if printf '%s' "$MAN" | grep -q '"manifests"'; then
CHILD=$(first_digest "$MAN"); [ -n "$CHILD" ] || return 0
MAN=$(fetch_manifest "$CHILD") || return 0
fi
CFG=$(first_digest "$MAN") || return 0
[ -n "$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)
echo "${P}|${B}|${R}"
}
# v1 >= v2 in version order?
at_least() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; }
forced() { case " $REBUILD " in *" all "*|*" $1 "*) return 0;; *) return 1;; esac; }
# Candidates: upstream tags that npm actually published, from MIN_VERSION up.
CANDIDATES=""
while read -r V; do
[ -n "$V" ] || continue
grep -qx "$V" /tmp/npm-versions || { echo "skip $V: not on npm"; continue; }
at_least "$V" "$MIN_VERSION" || continue
case " $SKIP_VERSIONS " in *" $V "*) echo "skip $V: listed in SKIP_VERSIONS"; continue;; esac
CANDIDATES="$CANDIDATES $V"
done < <(sort -V /tmp/upstream-versions)
echo "candidates:$CANDIDATES"
BUILDS=""
for V in $CANDIDATES; do
if ! tag_exists "$V"; then
echo "$V: missing -> build"; BUILDS="$BUILDS $V"
elif forced "$V"; then
echo "$V: rebuild requested"; BUILDS="$BUILDS $V"
else
HAVE=$(tag_toolchain "$V")
if [ "$HAVE" != "$WANT" ]; then echo "$V: stale ($HAVE) -> rebuild"; BUILDS="$BUILDS $V"; fi
fi
done
# Newest release candidate = what :latest points at.
CURRENT=$(printf '%s\n' $CANDIDATES | grep -v alpha | sort -V | tail -1)
echo "planned builds:$BUILDS"
echo "current (:latest): $CURRENT"
{
echo "builds=${BUILDS}"
echo "current=${CURRENT}"
echo "pnpm=${PNPM_LATEST}"
echo "base_digest=${BASE_DIGEST}"
echo "recipe_hash=${RECIPE_HASH}"
} >> "$GITHUB_OUTPUT"
- name: Build, smoke-test, and push
if: steps.plan.outputs.builds != ''
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
PNPM_VERSION: ${{ steps.plan.outputs.pnpm }}
BASE_DIGEST: ${{ steps.plan.outputs.base_digest }}
RECIPE_HASH: ${{ steps.plan.outputs.recipe_hash }}
CURRENT: ${{ steps.plan.outputs.current }}
CI_RUN: ${{ github.run_number }}
run: |
set -u
echo "$TOKEN" | docker login "$REGISTRY" -u "${{ github.repository_owner }}" --password-stdin
GIT_SHA=$(git rev-parse HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
SMOKE="dsh-smoke-${{ github.run_id }}"
# Build one version; smoke-test on a fresh root-owned volume (what a
# new Unraid appdata dir looks like); push only if it passes.
# 200 = pre-token versions, 303/401 = 0.1.2+ launch-token gate.
build_one() (
set -eux
V="$1"; REF="${REGISTRY}/${IMAGE}:${V}"
docker build \
--build-arg "DSH_VERSION=${V}" \
--build-arg "PNPM_VERSION=${PNPM_VERSION}" \
--build-arg "BASE_DIGEST=${BASE_DIGEST}" \
--build-arg "RECIPE_HASH=${RECIPE_HASH}" \
--build-arg "GIT_SHA=${GIT_SHA}" \
--build-arg "CI_RUN=${CI_RUN}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
-t "$REF" .
docker rm -f "$SMOKE" 2>/dev/null || true
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)" = "$V" ]
[ "$(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 '
const os = require("os");
const nic = Object.values(os.networkInterfaces()).flat()
.find(i => i && i.family === "IPv4" && !i.internal);
fetch("http://" + nic.address + ":3080/").then(r => {
process.exit([200, 303, 401].includes(r.status) ? 0 : 1);
}).catch(() => process.exit(2));'
then OK=1; break; fi
sleep 2
done
if [ -z "$OK" ]; then docker logs "$SMOKE" | tail -40; exit 1; fi
docker rm -f "$SMOKE"; docker volume rm -f "$SMOKE" || true
docker push "$REF"
)
FAILED=""
for V in ${{ steps.plan.outputs.builds }}; do
if build_one "$V"; then
echo "::notice::built ${IMAGE}:${V}"
else
docker rm -f "$SMOKE" 2>/dev/null || true; docker volume rm -f "$SMOKE" 2>/dev/null || true
echo "::warning::build failed for ${V}; any existing image for it is left untouched"
FAILED="$FAILED $V"
fi
done
if [ -n "$FAILED" ]; then
echo "failed versions:$FAILED"
case " $FAILED " in *" $CURRENT "*) echo "::error::the current version (${CURRENT}) failed to build"; exit 1;; esac
fi
# `:latest` = the newest release-candidate version. A manifest copy
# through the registry API: identical digest, nothing rebuilt.
- name: Point :latest at 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'
digest_of() { curl -sI -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" "https://${REGISTRY}/v2/${IMAGE}/manifests/$1" | grep -i '^docker-content-digest' | awk '{print $2}' | tr -d '\r'; }
WANT=$(digest_of "$CURRENT")
[ -n "$WANT" ] || { echo "::error::${CURRENT} has no image in the registry"; exit 1; }
HAVE=$(digest_of latest || true)
echo "latest -> ${CURRENT} (${WANT}); currently ${HAVE:-unset}"
[ "$WANT" != "$HAVE" ] || { echo "latest already current"; exit 0; }
curl -s -u "${OWNER}:${TOKEN}" -H "Accept: $ACCEPT" -D /tmp/hdr -o /tmp/manifest "https://${REGISTRY}/v2/${IMAGE}/manifests/${CURRENT}"
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 -> ${CURRENT} ($(digest_of latest))"
# One release per built version, on the mirrored upstream tag, titled
# like upstream and carrying upstream's notes; updated in place when the
# image is rebuilt. Runs on the image base so the runner needs nothing.
- name: Publish releases
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REG_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
set -eu
MAIN_SHA=$(git rev-parse HEAD)
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 \
"$BASE_IMAGE" node - < .gitea/scripts/publish-releases.js