screentinker/.github/workflows/release.yml
screentinker 243fc6688c
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 / Boot smoke + version check (push) Waiting to run
Release notes: publish the changelog entry, not the commit subjects (#273)
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.
2026-08-13 22:31:32 -05:00

238 lines
10 KiB
YAML

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: |
VERSION="$(cat VERSION)"
echo "version=$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"
# #80: a version carrying a -suffix (e.g. 1.9.0-rc1) is a pre-release.
case "$VERSION" in *-*) PRE=true ;; *) PRE=false ;; esac
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing ${GITHUB_REF_NAME} (version $VERSION, prerelease=$PRE); previous tag: ${PREV:-<none>}"
- 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
cp tizen/ScreenTinker.wgt ScreenTinker.wgt
ls -la ScreenTinker.wgt
- name: Build BrightSign autorun.zip (single-file player installer)
run: |
chmod +x scripts/build-autorun-zip.sh
./scripts/build-autorun-zip.sh -o autorun.zip
ls -la autorun.zip
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
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' \
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt brightsign
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
ls -la "$OUT"
- name: Generate release notes
run: |
PREV="${{ steps.ver.outputs.prev }}"
VERSION="${{ steps.ver.outputs.version }}"
# Prefer the hand-written CHANGELOG section for this version.
#
# The generated list is commit SUBJECTS, which describe the work, not the release: cutting
# 1.9.34 produced a page reading "chore(release): v1.9.34" and one changelog commit, while
# the entry describing single sign-on, the native-dependency removal, the update fixes and
# every outside contributor sat in CHANGELOG.md and was never published. The notes on the
# release page are what most people actually read, so they should be the written ones.
#
# awk rather than sed: the body contains regex metacharacters and markdown that a sed range
# would mangle. This takes everything between `## <version>` and the next `## ` heading.
CHANGELOG_BODY="$(awk -v v="## $VERSION" '
$0 == v {found=1; next}
found && /^## / {exit}
found {print}
' CHANGELOG.md)"
{
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo
if [ -n "$(printf '%s' "$CHANGELOG_BODY" | tr -d '[:space:]')" ]; then
echo "$CHANGELOG_BODY"
else
# No entry for this version — fall back to commit subjects rather than publish a
# release with no notes at all. scripts/bump-version.sh already warns when the
# CHANGELOG has no matching heading; this is the same gap showing up downstream.
echo "_No CHANGELOG entry for $VERSION; listing commits instead._"
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
fi
echo
echo "### Artifacts"
echo "- \`${TARBALL}\` - bundle: server + frontend source + the Tizen .wgt (the signed Android APK is added at the root during release finalization)."
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 "- \`autorun.zip\` - BrightSign player installer. Drop it on the root of a player's"
echo " storage (microSD, USB, or internal flash) and power-cycle: it unpacks itself and"
echo " reboots into the player. Edit \`screentinker.json\` inside the archive first to"
echo " point it at your own server."
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
else
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
} > RELEASE_NOTES.md
cat RELEASE_NOTES.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# #80: pre-release tags publish as a GitHub *pre-release* (not "Latest"),
# which also keeps the /releases/latest API pointing at the last stable.
PRERELEASE_FLAG=""
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && PRERELEASE_FLAG="--prerelease"
gh release create "${{ steps.ver.outputs.tag }}" \
$PRERELEASE_FLAG \
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
autorun.zip \
tizen/ScreenTinker.wgt
docker:
name: Docker image (amd64 + arm64) -> ghcr
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- id: ver
run: |
VERSION="$(cat VERSION)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# #80: move :latest only for final releases - a pre-release (1.9.0-rc1) must
# not repoint :latest onto untested code (anyone on :latest pulls it on restart).
TAGS="ghcr.io/screentinker/screentinker:$VERSION"
case "$VERSION" in
*-*) echo "Pre-release $VERSION: :latest will NOT be moved" ;;
*) TAGS="${TAGS}"$'\n'"ghcr.io/screentinker/screentinker:latest" ;;
esac
{ echo "tags<<__EOF__"; printf '%s\n' "$TAGS"; echo "__EOF__"; } >> "$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 }}
# ghcr refused this push on the 1.9.29 release 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.
#
# That is worth one retry rather than a failed release, and it is worst exactly here: the
# GitHub Release job has already published by this point, so a failure leaves a tag that
# exists with no image behind it. Anyone deploying from ghcr — alpha, and every self-hoster
# pulling :latest — sees a version that is announced and unpullable, which reads as a broken
# release rather than a hiccup at a registry.
- uses: docker/build-push-action@v6
id: push
continue-on-error: true
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.ver.outputs.tags }}
- name: Pause before retrying the push
if: steps.push.outcome == 'failure'
run: sleep 45
# No continue-on-error: a second refusal is a real failure and must fail the release.
- name: Retry the push
if: steps.push.outcome == 'failure'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.ver.outputs.tags }}
# 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).