1 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 9b7511f8bf chore(ops): version the release scripts
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
They existed only in /opt/ops on the VPS — no history, no backup. Rebuilding
that machine would have taken the whole publishing process with it, including
the Android signing flow written today.

/opt/ops now symlinks here so there is one copy rather than two that drift.
No secrets move: the scripts read tokens and the signing key from paths under
/root that stay on the VPS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:21:00 -03:00
4 changed files with 237 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Scripts de operação da instância
Rodam na VPS, em `/opt/ops`, onde os arquivos são links simbólicos para esta
pasta — o repositório é a única cópia de verdade. Antes disso eles existiam só
na VPS, sem versão nem backup: uma reinstalação do servidor levaria junto todo
o processo de publicação.
| script | o que faz |
| --- | --- |
| `call-check.sh` | Diz se tem alguém em call. Roda antes de reiniciar o servidor. |
| `release-desktop.sh` | Espelha o fork no GitHub e dispara a compilação dos instaladores de desktop. |
| `release-android.sh` | Compila o APK no GitHub, assina na VPS e publica no Gitea. |
## O que estes scripts leem e não está aqui
Nada de segredo entra no repositório. Os arquivos abaixo ficam só na VPS, com
permissão `600`:
- `/root/.github-token` — dispara e baixa os builds do GitHub.
- `/root/.gitea-token` — publica os anexos das releases.
- `/root/android-signing/` — a chave que assina o APK, mais a senha dela.
A chave do Android merece atenção especial: ela é o que prova ao Android que
uma atualização veio de nós. Se for perdida, ninguém consegue atualizar por
cima — todo mundo tem que desinstalar e instalar de novo, e não existe
recuperação. Mantenha uma cópia fora da VPS.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Reports how many people are currently in a call on this instance.
#
# Asks LiveKit directly (ListRooms over its twirp API) rather than inferring
# from the app: LiveKit is the authority on who is actually connected to media.
# Prints the participant count and exits 0 when the instance is idle, 1 when
# someone is in a call — so it can gate a deploy.
set -euo pipefail
ENV_FILE=${ENV_FILE:-/opt/backspace/.env}
# `|| true`: under `set -o pipefail` a grep with no match aborts the script
# before the check below, exiting 1 — which in this script's contract means
# "someone is in a call" rather than "I could not tell".
KEY=$(grep -E '^LIVEKIT_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
SECRET=$(grep -E '^LIVEKIT_API_SECRET=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
[ -n "$KEY" ] && [ -n "$SECRET" ] || { echo "LiveKit credentials not found in $ENV_FILE" >&2; exit 2; }
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
now=$(date +%s)
header=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64url)
payload=$(printf '{"iss":"%s","sub":"%s","nbf":%d,"exp":%d,"video":{"roomList":true}}' \
"$KEY" "$KEY" "$now" "$((now + 60))" | b64url)
sig=$(printf '%s.%s' "$header" "$payload" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)
jwt="$header.$payload.$sig"
resp=$(curl -sS --max-time 10 -w '\n%{http_code}' -X POST http://127.0.0.1:7880/twirp/livekit.RoomService/ListRooms \
-H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' -d '{}') || {
echo "LiveKit unreachable — refusing to report idle" >&2; exit 2; }
code=$(printf '%s' "$resp" | tail -n1)
resp=$(printf '%s' "$resp" | sed '$d')
# A failed query returns no participant counts, which would read as "idle" and
# green-light a deploy on top of a live call. Anything but a well-formed 200 is
# treated as unknown, not as empty.
if [ "$code" != "200" ] || ! printf '%s' "$resp" | grep -q '"rooms"'; then
echo "LiveKit answered HTTP $code without a room list — status unknown, not deploying" >&2
exit 2
fi
# numParticipants is omitted by protobuf JSON when zero, so absence means empty.
total=$(printf '%s' "$resp" | grep -oE '"numParticipants":[0-9]+' | cut -d: -f2 | paste -sd+ - | bc 2>/dev/null || true)
total=${total:-0}
if [ "$total" -gt 0 ]; then
echo "EM CALL: $total participante(s) — nao deployar"
exit 1
fi
echo "OCIOSO: ninguem em call"
exit 0
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Baixa o APK compilado no GitHub, assina aqui e publica no Gitea.
#
# A chave de assinatura fica nesta VPS e nao sobe para o GitHub. Ela e o que
# prova que uma atualizacao veio de nos: um APK assinado com outra chave nao
# instala por cima do instalado, o Android recusa. Por isso o runner entrega o
# APK sem assinatura e a assinatura acontece aqui.
#
# Uso: release-android.sh [run_id]
# sem run_id, dispara um build novo e espera terminar.
set -euo pipefail
REPO=${REPO:-/opt/backspace}
GH_REPO=${GH_REPO:-syncwrld/resenhacord}
TOOLS=/opt/ops/android-tools/build-tools
KEYSTORE=/root/android-signing/resenhacord.keystore
PASSFILE=/root/android-signing/password.txt
OUT=/opt/ops/android-out
GITEA_API=https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TOKEN=$(cat /root/.github-token)
gh() { curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
# apksigner e um script que chama java, e esta VPS nao tem JDK instalado (nem
# deve ter: ela roda a producao, nao compila nada). Roda num container, com as
# ferramentas, a chave e a pasta de saida montadas. zipalign e binario nativo e
# roda direto no host.
apksigner() {
docker run --rm \
-v "$TOOLS":/tools:ro \
-v /root/android-signing:/ks:ro \
-v "$OUT":/out \
eclipse-temurin:21-jdk /tools/apksigner "$@"
}
for f in "$TOOLS/apksigner" "$TOOLS/zipalign" "$KEYSTORE" "$PASSFILE"; do
[ -e "$f" ] || { echo "faltando: $f" >&2; exit 1; }
done
RUN_ID=${1:-}
if [ -z "$RUN_ID" ]; then
echo "==> Disparando o build no GitHub"
gh -o /dev/null -X POST \
"https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/dispatches" \
-d '{"ref":"main"}'
sleep 10
RUN_ID=$(gh "https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/runs?per_page=1" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['workflow_runs'][0]['id'])")
echo " run=$RUN_ID"
echo "==> Esperando (uns 4 min)"
for _ in $(seq 1 90); do
read -r status conclusion < <(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID" \
| python3 -c "import sys,json;r=json.load(sys.stdin);print(r['status'],r['conclusion'])")
[ "$status" = "completed" ] && break
sleep 20
done
[ "${conclusion:-}" = "success" ] || { echo "build falhou: ${conclusion:-timeout}" >&2; exit 1; }
fi
echo "==> Baixando o APK sem assinatura"
mkdir -p "$OUT"
URL=$(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" \
| python3 -c "import sys,json;a=json.load(sys.stdin)['artifacts'];print(a[0]['archive_download_url'] if a else '')")
[ -n "$URL" ] || { echo "o run $RUN_ID nao tem artefato" >&2; exit 1; }
curl -sL -H "Authorization: Bearer $TOKEN" "$URL" -o "$OUT/apk.zip"
rm -f "$OUT/resenhacord-unsigned.apk"
unzip -o -q "$OUT/apk.zip" -d "$OUT"
VERSION=$(python3 -c "import json;print(json.load(open('$REPO/package.json'))['version'])")
SIGNED="$OUT/Resenhacord-$VERSION.apk"
echo "==> Alinhando e assinando $VERSION"
# --key-pass omitido de proposito: sem ele o apksigner reusa a senha do
# keystore, que e a mesma. Passar o mesmo arquivo nas duas opcoes falha — ele le
# os arquivos de senha em sequencia e bate no fim do arquivo na segunda leitura.
# zipalign antes do apksigner, nao depois: o alinhamento move bytes dentro do
# zip e invalidaria a assinatura se viesse por ultimo.
"$TOOLS/zipalign" -p -f 4 "$OUT/resenhacord-unsigned.apk" "$OUT/aligned.apk"
apksigner sign \
--ks /ks/resenhacord.keystore --ks-pass file:/ks/password.txt \
--ks-key-alias resenhacord \
--out "/out/$(basename "$SIGNED")" /out/aligned.apk
rm -f "$OUT/aligned.apk" "$OUT/apk.zip"
echo "==> Conferindo a assinatura"
apksigner verify --print-certs "/out/$(basename "$SIGNED")" | head -5
if [ "${1:-}" = "--no-publish" ] || [ "${PUBLISH:-1}" = "0" ]; then
echo "APK pronto: $SIGNED"
exit 0
fi
echo "==> Publicando no Gitea"
GITEA_TOKEN=$(cat /root/.gitea-token 2>/dev/null || true)
if [ -z "$GITEA_TOKEN" ]; then
echo "sem /root/.gitea-token — APK pronto localmente em $SIGNED"
exit 0
fi
api() { curl -s -w '\n%{http_code}' -X "$1" -H "Authorization: token $GITEA_TOKEN" "$GITEA_API$2" "${@:3}"; }
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
R=$(api GET "/releases/tags/latest")
ID=$(body "$R" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
[ -n "$ID" ] || { echo "release 'latest' nao encontrada no Gitea" >&2; exit 1; }
# Remove o APK anterior: dois anexos com o mesmo nome fazem o Gitea servir o
# mais antigo, e quem baixasse pegaria a versao velha sem erro nenhum.
api GET "/releases/$ID/assets" | sed '$d' \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
| while read -r aid; do
[ -n "$aid" ] && api DELETE "/releases/$ID/assets/$aid" >/dev/null && echo " removido anexo $aid"
done
NAME=$(basename "$SIGNED")
R=$(api POST "/releases/$ID/assets?name=$NAME" -F "attachment=@$SIGNED")
[ "$(code "$R")" = "201" ] || { echo "falha no upload — HTTP $(code "$R"): $(body "$R")" >&2; exit 1; }
echo "publicado: $NAME"
echo "https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/$NAME"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Espelha o fork no GitHub e dispara a compilacao dos instaladores.
#
# O GitHub e usado apenas como maquina de build: ele fornece runners Windows
# prontos, e o modulo nativo do audio (WASAPI) so compila la. O Gitea continua
# sendo o repositorio principal.
set -euo pipefail
REPO=${REPO:-/opt/backspace}
TAG=${1:-}
cd "$REPO"
if [ -z "$TAG" ]; then
echo "uso: $0 vX.Y.Z" >&2
exit 2
fi
# `ssh -T git@github.com` always exits 1 (GitHub refuses shell access), and
# under `pipefail` that status wins over grep's — so piping straight into the
# `if` reported failure even on a working key. Capture, then test.
SSH_PROBE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -T git@github.com 2>&1 || true)"
if ! printf '%s' "$SSH_PROBE" | grep -q "successfully authenticated"; then
echo "GitHub ainda nao aceita a chave desta VPS." >&2
echo "Adicione esta chave em https://github.com/settings/keys :" >&2
cat ~/.ssh/id_ed25519.pub >&2
exit 1
fi
echo "==> Espelhando main no GitHub"
git push github main
echo "==> Marcando $TAG (e a tag que dispara o workflow release.yml)"
git tag -f "$TAG"
git push -f github "$TAG"
echo
echo "Compilacao disparada. Acompanhe em:"
echo " https://github.com/syncwrld/resenhacord/actions"
echo "Os instaladores aparecem em Releases quando terminar (~15 min)."