ci: release pipeline (tarball, tizen wgt, multi-arch docker) + Docker packaging

- .github/workflows/release.yml: on a v* tag - verify the tag matches VERSION
  (fail-fast guard), run tests, build a source tarball + the unsigned Tizen .wgt
  and publish a GitHub Release with generated notes, and build+push a multi-arch
  (amd64 + arm64) image to ghcr.io/screentinker/screentinker:<version> + :latest.
  The Release (artifacts) and the docker push are independent jobs, so an
  arm64/QEMU docker failure does not block the GitHub Release and is re-runnable.
  Nothing deploys to prod. APK-build-in-CI left as a TODO (keystore secret).
- Dockerfile + .dockerignore: multi-stage node:20-slim image with server +
  frontend + VERSION + scripts; DATA_DIR=/data volume for db/uploads/jwt-secret.
  Verified to build, boot, serve the dashboard + web player, and persist state.
- docker-compose.example.yml: /data volume, SELF_HOSTED, a node-fetch healthcheck
  against /api/status, and an admin-lockout recovery note (reset-admin.js).
- server.js: resolve the OTA APK from DATA_DIR first (a container can mount one
  at /data/ScreenTinker.apk), fall back to the legacy in-repo path, 404 gracefully.
- ci.yml: bump checkout/setup-node to v6 (clears the Node-20 action deprecation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-10 13:44:51 -05:00
parent e2cd64054a
commit 4771f62623
6 changed files with 277 additions and 9 deletions

38
.dockerignore Normal file
View file

@ -0,0 +1,38 @@
# git + CI
.git
.github
.gitignore
# deps - reinstalled fresh in the build
node_modules
**/node_modules
# mutable state (lives on the /data volume, never baked into the image).
# Note: the *.db* patterns drop the database FILES; we must NOT exclude the
# server/db/ directory itself - it holds database.js + schema.sql.
*.db
*.db-wal
*.db-shm
*.db.*
server/uploads
server/certs
# secrets
.env
**/.env
# not needed at runtime (NOTE: scripts/ IS needed - database.js requires
# scripts/migrate-multitenancy at boot - so it must stay in the image)
server/test
android
tizen
video
*.apk
*.wgt
*.tar.gz
# docs / editor cruft
*.md
.vscode
.idea
.DS_Store

View file

@ -24,8 +24,8 @@ jobs:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
@ -37,8 +37,8 @@ jobs:
name: Boot smoke + version check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm

150
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,150 @@
name: Release
# Fires when a version tag is pushed (e.g. v1.8.0). Builds + publishes artifacts
# only - nothing here deploys to production.
on:
push:
tags: ['v*']
permissions:
contents: write # create the GitHub Release
packages: write # push the image to ghcr.io
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false # never cancel a release mid-publish
jobs:
# Fail-fast: a hand-pushed tag that disagrees with VERSION must not publish
# anything (the artifacts would report the wrong version). Gates everything.
verify:
name: Verify tag matches VERSION
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Assert pushed tag equals VERSION
run: |
TAG="${GITHUB_REF_NAME#v}"
FILE="$(cat VERSION)"
echo "pushed tag: ${GITHUB_REF_NAME} (stripped: $TAG) VERSION file: $FILE"
if [ "$TAG" != "$FILE" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match VERSION ($FILE) - refusing to publish."
exit 1
fi
echo "OK: tag matches VERSION ($FILE)"
test:
name: Tests
needs: verify
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
- run: npm ci
- run: npm test
artifacts:
name: Tarball + Tizen .wgt + GitHub Release
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history, for release notes
- name: Resolve version + previous tag
id: ver
run: |
echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
PREV="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Releasing ${GITHUB_REF_NAME} (version $(cat VERSION)); previous tag: ${PREV:-<none>}"
- name: Build source tarball
run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
tar czf "$OUT" \
--exclude='node_modules' --exclude='.git' --exclude='.github' \
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
--exclude='*.apk' --exclude='*.wgt' \
server frontend scripts VERSION README.md LICENSE .env.example
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
ls -la "$OUT"
- name: Build Tizen .wgt (unsigned in CI)
run: |
chmod +x tizen/build-wgt.sh
( cd tizen && ./build-wgt.sh ) # no Tizen CLI on the runner => unsigned zip
ls -la tizen/ScreenTinker.wgt
- name: Generate release notes
run: |
PREV="${{ steps.ver.outputs.prev }}"
{
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
fi
echo
echo "### Artifacts"
echo "- \`${TARBALL}\` - server + frontend source tarball (Node 20; see the README to run)."
echo "- \`ScreenTinker.wgt\` - Tizen TV web app, **unsigned - for inspection only**."
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
echo " at \`https://<your-instance>/player\` (no signing needed)."
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
} > RELEASE_NOTES.md
cat RELEASE_NOTES.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.ver.outputs.tag }}" \
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
tizen/ScreenTinker.wgt
docker:
name: Docker image (amd64 + arm64) -> ghcr
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- id: ver
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}
ghcr.io/screentinker/screentinker:latest
# TODO (deferred): build + sign the Android APK in CI. Requires the release
# keystore + passwords as encrypted Actions secrets. For now the maintainer
# attaches a signed APK out-of-band (and self-hosters mount one at
# /data/ScreenTinker.apk).

37
Dockerfile Normal file
View file

@ -0,0 +1,37 @@
# ScreenTinker server image: serves the dashboard, the web player, and the
# device API. All mutable state (db, uploads, jwt secret) lives under /data so it
# survives container restarts - mount a volume there. A built ScreenTinker.apk
# can be mounted at /data/ScreenTinker.apk to enable OTA APK downloads.
#
# No TLS in the image: it listens on plain HTTP :3001. Front it with a
# TLS-terminating reverse proxy / Cloudflare in production.
# --- builder: install production deps (native: better-sqlite3, sharp) ---
FROM node:20-slim AS builder
WORKDIR /app/server
# build toolchain in case a native prebuild is missing for the target arch
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev
# --- runtime ---
FROM node:20-slim
ENV NODE_ENV=production
# Relocate all state onto the volume (config.js reads DATA_DIR; unset would use
# the in-repo paths, which we do not want in a container).
ENV DATA_DIR=/data
WORKDIR /app/server
# App source (node_modules/test/db/uploads/certs are excluded via .dockerignore),
# then the built deps, the frontend the server serves, and the VERSION file it
# reads as ../VERSION.
COPY server/ /app/server/
COPY --from=builder /app/server/node_modules /app/server/node_modules
COPY frontend/ /app/frontend/
COPY VERSION /app/VERSION
# database.js requires scripts/migrate-multitenancy at boot
COPY scripts/ /app/scripts/
VOLUME ["/data"]
EXPOSE 3001
CMD ["node", "server.js"]

View file

@ -0,0 +1,33 @@
# Example docker-compose for self-hosting ScreenTinker.
# cp docker-compose.example.yml docker-compose.yml # then edit to taste
# The container serves plain HTTP on 3001 - front it with a TLS-terminating
# reverse proxy or Cloudflare in production.
services:
screentinker:
image: ghcr.io/screentinker/screentinker:latest
# ...or build from source instead of pulling:
# build: .
restart: unless-stopped
ports:
- "3001:3001"
environment:
SELF_HOSTED: "true" # first registered user becomes admin, no billing
# JWT_SECRET: "set-a-long-random-string" # else one is generated under /data/certs
# DISABLE_HOMEPAGE: "true" # redirect / to the app instead of the landing page
volumes:
- st-data:/data # db, uploads, and the jwt secret persist here
# To enable OTA APK downloads, mount a built APK read-only:
# - ./ScreenTinker.apk:/data/ScreenTinker.apk:ro
healthcheck:
# image is node:20-slim (no curl) - use node's built-in fetch
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/api/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s # first boot runs db migrations
# Locked out of the admin account? Reset it with:
# docker exec -it screentinker node scripts/reset-admin.js
volumes:
st-data:

View file

@ -482,8 +482,8 @@ app.use('/api/status', require('./routes/status'));
// APK version check endpoint (public, used by devices to check for updates)
app.get('/api/update/check', (req, res) => {
const currentVersion = req.query.version;
const apkPath = path.join(__dirname, '..', 'ScreenTinker.apk');
const apkExists = fs.existsSync(apkPath);
const apkPath = resolveApkPath();
const apkExists = apkPath !== null;
const apkSize = apkExists ? fs.statSync(apkPath).size : 0;
const apkModified = apkExists ? fs.statSync(apkPath).mtimeMs : 0;
@ -575,16 +575,26 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, (
res.json(updated);
});
// Resolve the OTA APK. A copy under the data dir (DATA_DIR) wins, so a container
// operator can mount one at /data/ScreenTinker.apk; otherwise the legacy in-repo
// root path (unchanged when DATA_DIR is unset). Returns null if neither exists.
function resolveApkPath() {
for (const p of [path.join(config.dataDir, 'ScreenTinker.apk'), path.join(__dirname, '..', 'ScreenTinker.apk')]) {
if (fs.existsSync(p)) return p;
}
return null;
}
// Serve APK download
const apkPath = path.join(__dirname, '..', 'ScreenTinker.apk');
app.get('/download/apk', (req, res) => {
if (fs.existsSync(apkPath)) {
const apkPath = resolveApkPath();
if (apkPath) {
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(apkPath);
} else {
res.status(404).send(`<!DOCTYPE html><html><head><title>APK Not Found</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:500px;padding:24px}h1{color:#f87171;font-size:24px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:14px}p{line-height:1.6;color:#94a3b8}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet. To build it from source:</p><p><code>cd android</code><br><code>./gradlew assembleDebug</code><br><code>cp app/build/outputs/apk/debug/app-debug.apk ../ScreenTinker.apk</code></p><p>See the <a href="/" style="color:#3b82f6">README</a> for full build instructions.</p><p>Alternatively, use the <a href="/player" style="color:#3b82f6">web player</a> in any browser.</p></div></body></html>`);
res.status(404).send(`<!DOCTYPE html><html><head><title>APK Not Found</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:500px;padding:24px}h1{color:#f87171;font-size:24px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:14px}p{line-height:1.6;color:#94a3b8}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet. To build it from source:</p><p><code>cd android</code><br><code>./gradlew assembleDebug</code><br><code>cp app/build/outputs/apk/debug/app-debug.apk ../ScreenTinker.apk</code></p><p>See the <a href="/" style="color:#3b82f6">README</a> for full build instructions.</p><p>In Docker, mount a built APK at <code>/data/ScreenTinker.apk</code> (the data dir).</p><p>Alternatively, use the <a href="/player" style="color:#3b82f6">web player</a> in any browser.</p></div></body></html>`);
}
});