Publish scanservjs-helm chart for upstream v3.3.0
Validate, publish and promote ScanservJS / chart (push) Successful in 50s

This commit is contained in:
2026-10-19 21:22:12 +02:00
commit 54c01271c5
14 changed files with 349 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Validate, publish and promote ScanservJS
on:
push:
pull_request:
jobs:
chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: azure/setup-helm@v4
with:
version: v4.3.0
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Validate chart and scanner handoff
run: |
python3 -m venv "${RUNNER_TEMP}/chart-test"
"${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML
PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" ./test.sh
- name: Select immutable chart version
if: gitea.ref == 'refs/heads/main'
run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}"
- name: Package and publish chart
if: gitea.ref == 'refs/heads/main'
env:
PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}"
mkdir -p .chart-packages
helm package chart/scanservjs --destination .chart-packages --version "${CHART_VERSION}"
curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \
--request POST --upload-file ".chart-packages/scanservjs-${CHART_VERSION}.tgz" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
- name: Check current main
id: current-main
if: gitea.ref == 'refs/heads/main'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
git fetch --no-tags origin main
if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then
echo 'promote=true' >> "${GITEA_OUTPUT}"
else
echo 'promote=false' >> "${GITEA_OUTPUT}"
fi
- uses: actions/checkout@v4
if: steps.current-main.outputs.promote == 'true'
with:
repository: feedc0de/homelab-gitops
ref: main
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
- name: Promote chart
if: steps.current-main.outputs.promote == 'true'
working-directory: homelab-gitops
run: |
set -euo pipefail
./scripts/update-chart-version.sh scanner "${CHART_VERSION}"
git config user.name feedc0de
git config user.email feedc0de@users.noreply.code.brunner.ninja
git add bootstrap/scanner.yaml
if git diff --cached --quiet; then exit 0; fi
git commit -m "Deploy scanner chart ${CHART_VERSION}"
for attempt in 1 2 3; do
git push origin HEAD:main && exit 0
test "${attempt}" = 3 && exit 1
git fetch origin main
git rebase origin/main
done
+26
View File
@@ -0,0 +1,26 @@
# ScanservJS Helm chart
This independent chart uses the upstream ScanservJS image and creates the
scanner Deployment, Service, ConfigMap, and optional Ingress. Its Git tag tracks
the tested upstream release (`v3.3.0`). The chart owns the `Send to Paperless`
action in `files/send-to-paperless.js`; users select the shared PVC, output and
consume subpaths, scanner eSCL address, and ingress host through Helm values.
No site-specific address or recipient is built into the chart.
The action is a filesystem handoff, **not email**: it validates staged PDFs,
images, or image ZIPs, converts a ZIP to one PDF, atomically places the result
in the configured consume directory, and only then removes the staged file.
Give ScanservJS and Paperless access to the same claim. The homelab values are
in [homelab-gitops/apps/scanner](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/scanner).
```sh
helm repo add brunner.ninja https://brunner.ninja/charts
helm repo update brunner.ninja
helm upgrade --install scanner brunner.ninja/scanservjs \
--namespace paperless-ngx -f values.yaml --wait --timeout 10m
```
`./test.sh` checks the rendered topology and runs the real action with sample
PDF/JPEG files. `./install.sh values.yaml` installs from this checkout. CI
publishes and promotes the chart using `PACKAGE_USERNAME`, `PACKAGE_TOKEN`,
and `GITOPS_TOKEN` Actions secrets.
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: scanservjs
description: ScanservJS with an optional filesystem handoff to Paperless-ngx
type: application
version: 0.0.0
appVersion: 3.3.0
@@ -0,0 +1,57 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const output = process.env.SCAN_OUTPUT_DIR || '/var/lib/scanservjs/output';
const consume = process.env.PAPERLESS_CONSUME_DIR || '/paperless-consume';
module.exports = {
actions: [{
name: 'Send to Paperless',
async execute(fileInfo) {
const source = await fs.realpath(fileInfo.fullname);
const images = new Set(['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.gif', '.webp']);
const extension = path.extname(source).toLowerCase();
if (!source.startsWith(output + '/') || !(['.pdf', '.zip'].includes(extension) || images.has(extension))) {
throw new Error('Only staged PDFs, images, and image ZIPs can be sent');
}
let upload = source;
let temp;
let name = path.basename(source);
let pending;
try {
if (extension === '.zip') {
const AdmZip = require(require.resolve('adm-zip', {paths: ['/usr/lib/scanservjs']}));
const entries = new AdmZip(source).getEntries();
if (entries.length === 0 || entries.some(entry =>
entry.isDirectory || entry.entryName !== path.basename(entry.entryName) ||
!images.has(path.extname(entry.entryName).toLowerCase()))) {
throw new Error('ZIP must contain only scanned image pages');
}
temp = await fs.mkdtemp('/tmp/paperless-scan-');
const inputs = [];
for (const [index, entry] of entries.entries()) {
const input = path.join(temp, `${String(index).padStart(4, '0')}${path.extname(entry.entryName)}`);
await fs.writeFile(input, entry.getData());
inputs.push(input);
}
upload = path.join(temp, 'merged.pdf');
const {execFile} = require('node:child_process');
const {promisify} = require('node:util');
await promisify(execFile)('convert', [...inputs, upload]);
name = path.basename(source, path.extname(source)) + '.pdf';
}
const destination = path.join(consume, `${Date.now()}-${crypto.randomBytes(6).toString('hex')}-${name}`);
pending = destination + '.part';
await fs.copyFile(upload, pending);
await fs.rename(pending, destination);
await fs.unlink(source);
} catch (error) {
if (pending) await fs.rm(pending, {force: true});
throw error;
} finally {
if (temp) await fs.rm(temp, {recursive: true, force: true});
}
},
}],
};
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: scanner-config
data:
config.local.js: |
{{ .Files.Get "files/send-to-paperless.js" | indent 4 }}
@@ -0,0 +1,56 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: scanner
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: Recreate
selector:
matchLabels: {app: scanner}
template:
metadata:
labels: {app: scanner}
annotations:
checksum/config: {{ .Files.Get "files/send-to-paperless.js" | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
supplementalGroups: [1000]
initContainers:
- name: prepare-output
image: {{ printf "%s:%s" .Values.initImage.repository .Values.initImage.tag | quote }}
command: [sh, -c, 'mkdir -p /volume/{{ .Values.scanner.outputSubPath }} && chown 102:100 /volume/{{ .Values.scanner.outputSubPath }} && chmod 775 /volume/{{ .Values.scanner.outputSubPath }} && chmod g+w /volume/{{ .Values.scanner.consumeSubPath }}']
volumeMounts:
- {name: data, mountPath: /volume}
containers:
- name: scanner
image: {{ printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: [sh, -c, "sed -i '/^\\[options\\]/a discovery = disable' /etc/sane.d/airscan.conf && exec /entrypoint.sh"]
env:
- {name: AIRSCAN_DEVICES, value: {{ .Values.scanner.airscanDevices | quote }}}
- {name: SCAN_OUTPUT_DIR, value: {{ .Values.scanner.outputDirectory | quote }}}
- {name: PAPERLESS_CONSUME_DIR, value: {{ .Values.scanner.consumeDirectory | quote }}}
ports: [{name: http, containerPort: 8080}]
volumeMounts:
- {name: data, mountPath: {{ .Values.scanner.outputDirectory }}, subPath: {{ .Values.scanner.outputSubPath }}}
- {name: data, mountPath: {{ .Values.scanner.consumeDirectory }}, subPath: {{ .Values.scanner.consumeSubPath }}}
- {name: config, mountPath: /etc/scanservjs/config.local.js, subPath: config.local.js, readOnly: true}
startupProbe:
httpGet: {path: /, port: http}
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet: {path: /, port: http}
periodSeconds: 10
livenessProbe:
httpGet: {path: /, port: http}
periodSeconds: 30
resources:
{{ toYaml .Values.resources | indent 12 }}
volumes:
- name: data
persistentVolumeClaim: {claimName: {{ .Values.persistence.existingClaim }}}
- name: config
configMap: {name: scanner-config}
+24
View File
@@ -0,0 +1,24 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: scanner
{{- with .Values.ingress.annotations }}
annotations:
{{ toYaml . | indent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: scanner
port: {name: http}
{{- end }}
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Service
metadata:
name: scanner
spec:
selector: {app: scanner}
ports: [{name: http, port: 8080, targetPort: http}]
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"persistence": {
"type": "object",
"required": ["existingClaim"],
"properties": {"existingClaim": {"type": "string", "minLength": 1}}
},
"scanner": {
"type": "object",
"properties": {
"airscanDevices": {"type": "string"},
"outputSubPath": {"type": "string", "minLength": 1},
"consumeSubPath": {"type": "string", "minLength": 1}
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
image:
repository: sbs20/scanservjs
tag: ""
pullPolicy: IfNotPresent
initImage:
repository: docker.io/library/busybox
tag: 1.38.0
replicaCount: 1
scanner:
airscanDevices: ""
outputSubPath: scanner-staging
consumeSubPath: consume
outputDirectory: /var/lib/scanservjs/output
consumeDirectory: /paperless-consume
persistence:
existingClaim: ""
ingress:
enabled: false
className: ""
host: ""
annotations: {}
resources: {}
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")"
values=${1:?usage: ./install.sh VALUES_FILE}
./test.sh
helm upgrade --install scanner chart/scanservjs --namespace paperless-ngx --create-namespace --values "$values" --wait --timeout 10m
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
description=$(git describe --tags --long --match 'v[0-9]*' --dirty)
case "$description" in *-dirty) echo 'Refusing to package a dirty tree' >&2; exit 1 ;; esac
version=${description#v}
case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac
printf '%s\n' "$version"
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")"
helm lint --strict chart/scanservjs -f values.example.yaml
helm template scanner chart/scanservjs -n example -f values.example.yaml | python3 -c '
import os, pathlib, subprocess, sys, tempfile, yaml
objects=[x for x in yaml.safe_load_all(sys.stdin) if x]
one=lambda kind,name: next(x for x in objects if x["kind"]==kind and x["metadata"]["name"]==name)
d=one("Deployment","scanner")
s=one("Service","scanner")
assert d["spec"]["selector"]["matchLabels"]==s["spec"]["selector"]
assert d["spec"]["template"]["spec"]["volumes"][0]["persistentVolumeClaim"]["claimName"]=="document-storage"
assert all(k in d["spec"]["template"]["spec"]["containers"][0] for k in ("startupProbe","readinessProbe","livenessProbe"))
code=one("ConfigMap","scanner-config")["data"]["config.local.js"]
with tempfile.TemporaryDirectory() as temp:
root=pathlib.Path(temp); script=root/"config.local.js"; script.write_text(code)
output=root/"staged"; output.mkdir(); consume=root/"consume"; consume.mkdir()
env={**os.environ,"SCAN_OUTPUT_DIR":str(output),"PAPERLESS_CONSUME_DIR":str(consume)}
subprocess.run(["node","--check",str(script)],check=True)
for name,contents in (("test.pdf",b"%PDF-1.4\nscan\n"),("test.jpg",b"\xff\xd8\xffscan\xff\xd9")):
source=output/name; source.write_bytes(contents)
subprocess.run(["node","-e","require(process.argv[1]).actions[0].execute({fullname:process.argv[2]}).catch(e=>{console.error(e);process.exit(1)})",str(script),str(source)],check=True,env=env)
delivered=list(consume.glob("*"+name))
assert len(delivered)==1 and delivered[0].read_bytes()==contents
assert not source.exists() and not list(consume.glob("*.part"))
'
+12
View File
@@ -0,0 +1,12 @@
persistence:
existingClaim: document-storage
scanner:
airscanDevices: '"Office Scanner" = http://192.0.2.10:8080/eSCL, eSCL'
ingress:
enabled: true
className: traefik
host: scanner.example.com
resources:
requests:
cpu: 100m
memory: 256Mi