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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 16:10:21 -05:00
co-authored by Claude Opus 5
parent 56114b727c
commit 95c073d4c2
6 changed files with 171 additions and 3 deletions
+24 -3
View File
@@ -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
+101
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+19
View File
@@ -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"