From 95c073d4c2290356c9638092a030c26ed7f18d2a Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 31 Aug 2026 16:10:21 -0500 Subject: [PATCH] Publish the CRX from a timer, and refuse an unbumped version Mirrors the autotrader stack's auto-deploy: fetch every minute, act when master moves, lock against overlap, rewrite .last-check every run so a silent failure is visible. Only extension/ and deploy/ changes publish - repacking for a docs edit would republish an identical CRX every minute. The guard is the part that earns its place. Chrome ignores a CRX that is not strictly newer, so shipping extension changes without bumping manifest.json is a no-op that looks exactly like success until someone notices the fix never arrived. That now fails loudly instead. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 ++ deploy/README.md | 27 +++++++- deploy/auto-publish.sh | 101 ++++++++++++++++++++++++++++++ deploy/autofirmer-publish.service | 11 ++++ deploy/autofirmer-publish.timer | 11 ++++ deploy/install-autopublish.sh | 19 ++++++ 6 files changed, 171 insertions(+), 3 deletions(-) create mode 100755 deploy/auto-publish.sh create mode 100644 deploy/autofirmer-publish.service create mode 100644 deploy/autofirmer-publish.timer create mode 100755 deploy/install-autopublish.sh diff --git a/.gitignore b/.gitignore index 4e5a062..c1cc441 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,8 @@ scripts/.last-check # The deploy template is meant to be read, unlike a real .env !deploy/.env.example + +# publish bookkeeping +deploy/publish.log +deploy/.last-check +deploy/.publish.lock diff --git a/deploy/README.md b/deploy/README.md index 4681883..3d7434d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -66,13 +66,34 @@ second copy. The A record has to point at the droplet first. ## Publishing a release +Automatic. `autofirmer-publish.timer` fetches every minute and publishes when +master moves — the same shape as the autotrader stack's auto-deploy next door. +Install it once: + +```bash +./deploy/install-autopublish.sh # as root +``` + +Only changes under `extension/` or `deploy/` trigger a publish; anything else is +pulled and left alone, because repacking for a README edit would republish a +byte-identical CRX every minute. + +**Bump `version` in `extension/manifest.json` for every release.** Chrome ignores +a CRX that is not strictly newer than the copy it has, so republishing at the +same version is a silent no-op — the kind discovered days later, when a fix +everyone believed had shipped turns out never to have left the building. +`auto-publish.sh` refuses that case outright and logs why, rather than letting +it pass quietly. + +By hand, any time: + ```bash git pull && ./deploy/deploy.sh ``` -`deploy.sh` packs whatever is in `extension/` at the version its `manifest.json` -declares, so **bump that version or nothing updates** — Chrome compares versions -and ignores a CRX that is not newer than what it has. +Watch it with `tail -f deploy/publish.log`, and `cat deploy/.last-check` for the +heartbeat — that file is rewritten on every run, whether or not anything came +down, which is the only way to tell "running, nothing to do" from "not running". The browsers pick it up on Chrome's own schedule, roughly every five hours. They also pick it up within seconds if the dashboard is running, because the diff --git a/deploy/auto-publish.sh b/deploy/auto-publish.sh new file mode 100755 index 0000000..17b3848 --- /dev/null +++ b/deploy/auto-publish.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Publish a new CRX when master moves. Run by a systemd timer. +# +# Bash rather than Node, matching the autotrader stack next door: nothing on +# this host runs outside Docker, so there is no Node to depend on. +# +# Only a change under extension/ or deploy/ is worth acting on — the dashboard +# and the clicker are pulled by each instance itself, and repacking for a README +# edit would publish a byte-identical CRX every few minutes. +# +# ./auto-publish.sh # normal +# ./auto-publish.sh --dry-run # report only +set -euo pipefail + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" +BRANCH="${AUTOPUBLISH_BRANCH:-master}" +LOG="$ROOT/deploy/publish.log" +LAST_CHECK="$ROOT/deploy/.last-check" +LOCK="$ROOT/deploy/.publish.lock" +PUBLISHED_XML="${UPDATES_DIR:-/opt/autofirmer-updates}/public/updates.xml" +STALE_LOCK_SECS=3600 +LOG_MAX_BYTES=262144 +LOG_KEEP_LINES=500 +DRY_RUN=0 +[ "${1:-}" = "--dry-run" ] && DRY_RUN=1 + +log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$LOG"; } +record() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" > "$LAST_CHECK" 2>/dev/null || true; } + +# Trimmed once per run, before anything is written: the no-change path writes +# nothing, so this is the only moment the file can have grown. +if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG")" -gt "$LOG_MAX_BYTES" ]; then + tail -n "$LOG_KEEP_LINES" "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" +fi + +if [ "$DRY_RUN" -eq 0 ] && [ -f "$LOCK" ]; then + LOCK_AGE=$(( $(date +%s) - $(cat "$LOCK" 2>/dev/null || echo 0) )) + if [ "$LOCK_AGE" -lt "$STALE_LOCK_SECS" ]; then + exit 0 # already publishing; say nothing + fi + log "clearing a stale lock (${LOCK_AGE}s old)" +fi +[ "$DRY_RUN" -eq 0 ] && date +%s > "$LOCK" + +OUTCOME="interrupted" +cleanup() { [ "$DRY_RUN" -eq 0 ] && { rm -f "$LOCK"; record "$OUTCOME"; }; } +trap cleanup EXIT + +git fetch origin "$BRANCH" --quiet +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse "origin/$BRANCH") + +if [ "$LOCAL" = "$REMOTE" ]; then + OUTCOME="up to date at ${LOCAL:0:8}" + exit 0 # the common path: silent +fi + +log "update available: ${LOCAL:0:8} -> ${REMOTE:0:8}" +CHANGED=$(git diff --name-only "$LOCAL" "$REMOTE") +TOUCHES_RELEASE=0 +echo "$CHANGED" | grep -qE '^(extension|deploy)/' && TOUCHES_RELEASE=1 + +if [ "$DRY_RUN" -eq 1 ]; then + log "dry run - would pull; release-relevant changes: $TOUCHES_RELEASE" + exit 0 +fi + +git pull --ff-only origin "$BRANCH" --quiet + +if [ "$TOUCHES_RELEASE" -eq 0 ]; then + # Pulled so the checkout stays current, but nothing that ships changed. + OUTCOME="pulled ${REMOTE:0:8}, nothing to publish" + log "$OUTCOME" + exit 0 +fi + +# The guard worth having. Chrome ignores a CRX that is not strictly newer than +# what it already has, so republishing at the same version is a silent no-op — +# the kind you discover days later, when a fix everyone believed had shipped +# turns out never to have left the building. +VERSION=$(python3 -c 'import json;print(json.load(open("extension/manifest.json"))["version"])') +LIVE=$(sed -n 's/.*[^a-z]version="\([0-9][^"]*\)".*/\1/p' "$PUBLISHED_XML" 2>/dev/null | head -1) + +if echo "$CHANGED" | grep -qE '^extension/' && [ "$VERSION" = "$LIVE" ]; then + log "REFUSED: extension/ changed but manifest.json is still $VERSION, which is" + log " already published. Chrome would ignore the new CRX. Bump the" + log " version and push again." + OUTCOME="refused - version $VERSION not bumped" + exit 1 +fi + +if ./deploy/deploy.sh >> "$LOG" 2>&1; then + OUTCOME="published $VERSION at ${REMOTE:0:8}" + log "$OUTCOME" +else + log "ABORTED: deploy.sh failed - the previous CRX is still being served" + OUTCOME="publish failed - not switched" + exit 1 +fi diff --git a/deploy/autofirmer-publish.service b/deploy/autofirmer-publish.service new file mode 100644 index 0000000..63656e3 --- /dev/null +++ b/deploy/autofirmer-publish.service @@ -0,0 +1,11 @@ +[Unit] +Description=Publish a new AutoFirmer CRX when Gitea's master branch moves +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +WorkingDirectory=/opt/autofirmer +ExecStart=/opt/autofirmer/deploy/auto-publish.sh +# Packing pulls a node image on a cold cache; well inside this. +TimeoutStartSec=1800 diff --git a/deploy/autofirmer-publish.timer b/deploy/autofirmer-publish.timer new file mode 100644 index 0000000..139c701 --- /dev/null +++ b/deploy/autofirmer-publish.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check Gitea for AutoFirmer extension releases every minute + +[Timer] +OnBootSec=2min +OnUnitActiveSec=60s +AccuracySec=5s +Unit=autofirmer-publish.service + +[Install] +WantedBy=timers.target diff --git a/deploy/install-autopublish.sh b/deploy/install-autopublish.sh new file mode 100755 index 0000000..0e4cf37 --- /dev/null +++ b/deploy/install-autopublish.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Install the systemd timer that publishes a new CRX when master moves. +# Run as root on the droplet: ./deploy/install-autopublish.sh +set -euo pipefail +cd "$(dirname "$0")/.." +[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; } + +install -m 644 deploy/autofirmer-publish.service /etc/systemd/system/ +install -m 644 deploy/autofirmer-publish.timer /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now autofirmer-publish.timer + +echo +echo " installed. next run:" +systemctl list-timers autofirmer-publish.timer --no-pager | sed -n '2p' +echo +echo " logs: tail -f /opt/autofirmer/deploy/publish.log" +echo " heartbeat: cat /opt/autofirmer/deploy/.last-check" +echo " disable: systemctl disable --now autofirmer-publish.timer"