From 4771f62623f16d98186a31846a6f3b363cc3cf72 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 10 Jun 2026 13:44:51 -0500 Subject: [PATCH] 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: + :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) --- .dockerignore | 38 +++++++++ .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 150 ++++++++++++++++++++++++++++++++++ Dockerfile | 37 +++++++++ docker-compose.example.yml | 33 ++++++++ server/server.js | 20 +++-- 6 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile create mode 100644 docker-compose.example.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3cb63dd --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6966a0..aa73080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2d476e9 --- /dev/null +++ b/.github/workflows/release.yml @@ -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:-}" + + - 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:///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). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af7558d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..42e7e2e --- /dev/null +++ b/docker-compose.example.yml @@ -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: diff --git a/server/server.js b/server/server.js index 2844105..c43b94c 100644 --- a/server/server.js +++ b/server/server.js @@ -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(`APK Not Found

APK Not Available

The Android APK has not been compiled yet. To build it from source:

cd android
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk ../ScreenTinker.apk

See the README for full build instructions.

Alternatively, use the web player in any browser.

`); + res.status(404).send(`APK Not Found

APK Not Available

The Android APK has not been compiled yet. To build it from source:

cd android
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk ../ScreenTinker.apk

See the README for full build instructions.

In Docker, mount a built APK at /data/ScreenTinker.apk (the data dir).

Alternatively, use the web player in any browser.

`); } });