Commit graph

8 commits

Author SHA1 Message Date
screentinker 3f9459139f
Gate licences in CI and publish an SBOM (#282)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Licence gate + SBOM (production deps) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The licence audit that found org.json in the APK was run by hand. Nothing stopped the next
transitive dependency arriving the same way, and "we track licences" was a claim rather than
something anyone could check.

TWO GATES, BOTH FAIL CLOSED.

scripts/license-check.js audits the server's npm tree. scripts/android-license-check.js
resolves the real releaseRuntimeClasspath — everything that can enter the APK a customer
installs — and checks it against android/licenses.json, where each entry records the licence
AND the evidence for it. A dependency nobody has recorded fails the build. That is the case
worth catching: org.json reached customers because it arrived transitively and nothing ever
asked what licence it carried.

Denied: AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence. Weak copyleft (LGPL,
MPL, EPL, CDDL) is reported but does not fail — it is a judgement, and the judgement should
be made by someone who knows they are making it. Anything unrecognised fails; a package whose
licence we cannot identify is not one we ship.

⚠️ THE SERVER GATE INSTALLS --omit=dev, AND THAT IS THE POINT. A developer checkout carries
sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later. It is a test fixture generator that
never reaches a server, but a scanner pointed at a dev tree reports LGPL and contradicts the
answer we give customers. Auditing the production install is what makes the answer defensible.

SBOM. Every release now publishes screentinker-sbom-<version>.cdx.json — CycloneDX 1.5, every
production dependency with version, purl and licence, generated from a production install. CI
uploads one on every run too. That is what turns the claim into something a customer or an
underwriter can verify themselves.

Neither script takes a dependency: a gate that needs its own supply chain audited is worth
less than one that does not.

Verified by mutation rather than assumed. Injecting GPL-3.0-or-later, AGPL-3.0, the JSON
Licence, SSPL-1.0, and a package with no licence field each fail the server gate; MIT and
LGPL pass (LGPL reported). Removing the org.json exclusion fails the Android gate by name;
dropping a group from the policy fails it as unrecorded. Both restored, both green.

Found and fixed while building it: npm ls exits non-zero for any tree problem — an extraneous
package is enough — which made the gate abort instead of auditing. It now reads the listing
either way and only aborts on genuinely empty output.

docs/licensing.md records the policy, how to run the gates, and the dev-vs-production trap.

1676/1676 pass.
2026-08-14 15:36:38 -05:00
screentinker 8cb67122ad
Fix a load-time crash that took down any install collecting statistics (#279)
1.9.35 could not start with TELEMETRY_COLLECTOR=1. It threw before listening:

  ReferenceError: Cannot access 'db' before initialization
      at server.js:986

and systemd restarted it in a loop. Production was down until it was rolled back.

The mount passed the module-scope `db` to the collector's factory, but that binding is
declared ~275 lines further down. The inline handler this replaced only touched `db`
inside a request callback — which runs long after the binding exists — so moving the same
reference into a factory argument turned a lazy read into an eager one. Now resolved as
`require('./db/database').db`, the way every neighbouring call site in that region does it.

WHY NOTHING CAUGHT IT. The block is gated on a flag that only the statistics-collecting
deployment sets. It had therefore never executed in CI, on alpha, or in any test — 1676
tests, four green jobs, a clean alpha deploy, and the crashing line had still never run.
The unit tests mount the router directly and pass a db, which is precisely the part that
was fine.

So the boot smoke now boots WITH the collector enabled and asserts its routes answer:
/api/public/stats returns the expected shape, and a malformed report is refused with 400.
Booting alone would not be enough — the collector could mount and be broken.

Confirmed by reproduction: the released code fails to boot under that flag, and this does
not. 1676/1676 pass.
2026-08-14 12:17:41 -05:00
ScreenTinker 89cdd1052a CI: give the parity baselines the tags they judge against
main has been red since 4f7b4e3 for a reason visible nowhere in its diff.

The player-parity baselines describe what an UN-UPDATED display can do, so
they are judged against the SHIPPED source — `git show <latest tag>:…` —
rather than the working tree. actions/checkout defaults to a shallow clone
with no tags, so that lookup found nothing and the suite fell back to the
working tree. In a tree where today's QA had just fixed the players'
set_volume payload bug, the biconditional then demanded that
BASELINE.web/tizen gain audio.volume — for displays that cannot possibly
have the fix yet. Green locally, red in CI, and the failing assertion names
a baseline rather than the checkout that caused it.

So fetch the tags in the test job, and make the biconditionals SKIP when
there are none instead of asserting against the wrong source. That fallback
was never a slightly-early assertion; it was an inverted one. A skipped
assertion announces itself, a wrong one does not.

Verified both ways: with tags 15/15 pass, in a tagless shallow clone 13
pass + 2 skip + 0 fail (previously 1 fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 20:54:47 -05:00
screentinker eb794cefbd
ci(android): cache Gradle to stop wrapper-download flakes (#226)
The android-test job ran ./gradlew with no Gradle caching, so every run
re-downloaded the full Gradle distribution (gradle-8.5-bin.zip, ~130MB) from
services.gradle.org. A transient "Connection reset" mid-download failed the job
before any test ran (stack bottomed out in org.gradle.wrapper.Install.forceFetch)
— e.g. run 30063222009 on the npm-audit-fix PR, which passed on a plain re-run.

Add `cache: gradle` to the existing setup-java step. It caches ~/.gradle/caches
and ~/.gradle/wrapper; the wrapper cache holds the distribution zip, so cache-hit
runs skip the download entirely — removing the network dependency that flaked.
Also speeds the job up (build cache reuse).

Residual: a cold cache (first run per key, or a gradle-wrapper.properties change)
still downloads once. If that proves flaky too, a download retry can be layered on,
but caching turns "download every run" into "download rarely".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:24:21 -05:00
ScreenTinker c1b9c27f3a docs(api): OpenAPI spec, Redoc at /docs, CI spec-lint
- docs/openapi.yaml: the public, token-reachable surface only, with the auth model
  (Bearer st_) and a per-operation x-required-scope (read<write<full). JWT-only routers
  are excluded by design.
- Serve /openapi.yaml + /docs (Redoc via a vendored standalone bundle, no CDN so it
  works air-gapped; /docs is CSP-exempt). docs/ is bundled into the release tarball.
- CI: redocly lint + a public-only guard that fails loudly if a JWT-only path ever leaks
  into the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 2ccf3264a9 feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.

- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
  DST US+AU, overnight-wrap anchoring, timezone correctness, date
  boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
  Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
  test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
  own clock, idle with a "Nothing scheduled" message + 30s re-check when
  everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
  client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
  in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
  to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
  guarantee, and the scheduled-single-video re-render tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00
ScreenTinker 4771f62623 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>
2026-06-10 13:44:51 -05:00
ScreenTinker e2cd64054a ci: add CI workflow (unit tests + boot/version smoke)
- test job: node 20, npm ci + npm test in server/ (66 tests).
- smoke job: boot the server against a fresh SQLite db with SELF_HOSTED, then
  assert /api/status is ok and reports exactly the VERSION file (proves the
  single-source-of-truth wiring end to end).
- triggers: push and PR to main, plus manual workflow_dispatch. Concurrency
  cancels superseded in-flight runs per ref.
- upgrade-path job left as a TODO (needs a release tag earlier than HEAD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:24:46 -05:00