screentinker/.github/workflows/release.yml
ScreenTinker afe3f7f57f Retry the ghcr push once — a transient 403 should not cost a release
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
2026-08-06 09:10:17 -05:00

211 lines
8.9 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 }}"
{
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}\` - 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).