mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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:
parent
e2cd64054a
commit
4771f62623
38
.dockerignore
Normal file
38
.dockerignore
Normal 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
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -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
150
.github/workflows/release.yml
vendored
Normal 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
37
Dockerfile
Normal 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"]
|
||||
33
docker-compose.example.yml
Normal file
33
docker-compose.example.yml
Normal 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:
|
||||
|
|
@ -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>`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue