Cutting 1.9.34 produced a release page reading:
### Changes
- chore(release): v1.9.34
- Changelog: one 1.9.34 entry, and credit where it was missing
while the entry describing single sign-on, the removal of the last native image
dependency, three update failures and every outside contributor sat in
CHANGELOG.md and was never published. The notes on the release page are what most
people actually read; they should be the written ones.
The workflow now takes the section for the version being released and uses it as
the body. Commit subjects remain the fallback for a version with no entry, so a
release never publishes with no notes at all — scripts/bump-version.sh already
warns about a missing heading, and this is the same gap showing up downstream.
awk rather than sed for the extraction: the body contains regex metacharacters and
markdown that a sed range would mangle.
Verified against the real file: 1.9.34 extracts 269 lines and stops at the next
heading with all 13 contributor credits intact, 1.9.33 and 1.9.29 extract cleanly,
and a version with no entry yields nothing and takes the fallback.
v1.9.34's notes were corrected by hand after release; this is so the next one does
not need that.
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
ghcr refused the 1.9.29 push with "denied: permission_denied: Error from
intermediary with HTTP status code 403", then accepted the identical build on a
manual re-run minutes later. Nothing about the token, the permissions or the
workflow changed in between; the registry simply said no once.
The timing is what makes it worth handling. The GitHub Release job has already
published by the time this runs, so a failure here leaves a tag that exists with
no image behind it — alpha and every self-hoster pulling :latest see a version
that is announced and unpullable, which reads as a broken release rather than a
hiccup at a registry. It also needs a human to notice and re-run, which is the
part that does not scale.
One retry, after a pause, and a second refusal still fails the release.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
The rc2 workflow built autorun.zip and then published a release without it: the
edit that was supposed to add it to the gh release create asset list never
applied, and nothing asserted that it had. Built artifacts that quietly fail to
ship are worse than ones that fail loudly — the release looked green.
Attached to rc2 by hand; from rc3 the workflow does it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Four loose files that must all land intact, in the right place, is a poor way to
hand someone a player. autorun.zip is one file: drop it on the root of a
player's storage, power-cycle, and autozip.brs unpacks it in place and reboots
into the player. A half-copied set of loose files boots into something broken; a
half-copied zip simply fails to extract and leaves the player as it was.
Two rules the format imposes, both of which fail SILENTLY when broken, so the
build script asserts them instead of trusting them:
- the archive must expand to files at its root, with no wrapper directory. A
player extracts to the storage root, so a nested folder puts autorun.brs
somewhere the player never looks and the card appears to do nothing.
- autorun.brs must not sit next to autorun.zip on the storage root; its
presence stops the zip being processed at all.
autozip.brs renames the archive to autorun.zip.done after a successful extract,
which is what makes it idempotent — without that the player extracts, reboots,
extracts, reboots, a loop indistinguishable from a hardware fault. A FAILED
extract deliberately does not rename, so a truncated copy gets retried once
someone replaces it rather than being skipped forever.
It is volume-aware for the same reason autorun.brs is: a player may be booting
from internal flash because its card interface is dead, and extracting to "SD:/"
on such a unit writes to a volume that does not exist.
--server rewrites screentinker.json in the staging copy so a batch can be imaged
for a specific instance without hand-editing anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
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>
- 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>
Tagging a pre-release (e.g. v1.9.0-rc1) was unsafe. Four fixes:
1. bump-version.sh writes a numeric-only x.y.z to tizen/config.xml (strips a
-rc1/-beta.N suffix) so the .wgt still signs/installs; the full VERSION
(with the suffix) still drives server/Android/package.json.
2. release.yml flags the GitHub Release --prerelease for a -suffix version
(keeps it off "Latest" and out of the /releases/latest API).
3. release.yml moves docker :latest only for final releases - a pre-release no
longer repoints :latest onto untested code.
4. upgrade.sh excludes pre-release tags from its default selection - GNU
`sort -V` ranks 1.9.0-rc1 above the final 1.9.0, so the unfiltered default
would silently pick an RC (which then auto-OTAs to field kiosks). An explicit
`upgrade.sh v1.9.0-rc1` still works.
Verified the strip, tag selection, prerelease/tags logic, and YAML validity in
isolation.
Closes#80
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- release.yml: build the Tizen .wgt before the source tarball and bundle it in
(ScreenTinker.wgt at the tarball root). The signed Android APK is added by the
local finalize step (the keystore stays off CI).
- scripts/finalize-release.sh: after the release workflow publishes a tag, build
the signed APK locally, pull the CI-built unsigned .wgt from the release,
assemble a complete tarball (source + apk + wgt at the root, where /download/apk
resolves the apk after extraction), and upload the apk + complete tarball.
- .gitignore: ignore *.wgt and *.tar.gz so finalize temp files cannot be committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .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>
- 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>