Files
thoughtsync/.forgejo/workflows/ci.yml
T
bvandeusen 010e9a2f85
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 47s
server: bake the newest Android client into every image (operator call)
Reverses the placement decision made an hour ago. That one put the APK only on
the data volume, reasoning that ~55 MiB should not be charged to installs that
never touch Android. The operator's call is that ending the manual copy is worth
the megabytes, and it is their deployment.

CI now fetches the newest published client into the build context immediately
before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a
`docker compose pull` delivers a new server and a new client together.

**Always the rolling `dev` release — the newest build there is.** A versioned
image therefore carries the newest client rather than one pinned to that
version. Deliberate: the two negotiate a sync protocol version before linking, so
a mismatch is caught by the handshake, and pinning would buy nothing the
handshake does not already provide.

**Fetched by the JOB, never by the Dockerfile.** The release is private, and a
token used inside a build ends up in the context or a layer.

**It cannot fail the image build.** No release yet, a network blip, a first-ever
build — all of them log a warning and produce an image with no client, which is a
state the server already supports. Half a pair is cleaned up rather than shipped:
a sidecar without its APK is worse than neither, because the server would be
describing something it cannot serve.

**The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy
second, so an operator who deliberately drops a build in gets that build — and a
BROKEN drop-in falls through to the image's copy rather than taking the feature
offline, which is what makes the copy-order advice survivable instead of
load-bearing. Three tests cover the precedence, including that last case.

The baked copy lives inside the package, not under DATA_DIR: that path is a
volume mount, and anything the image wrote there would disappear behind it the
moment one is attached.

`client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI
step never ran; the artifacts themselves are gitignored, since a 55 MiB binary
does not belong in git history and is re-fetched on every build anyway.
2026-08-20 21:19:47 -04:00

196 lines
7.1 KiB
YAML

# CI runs first; build only proceeds if lint + typecheck pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
#
# main is the production line, so a merge to main rebuilds and moves :latest to its
# tip (family rule 46) — no version release required. The :<sha> image is the
# immutable rollback unit for every build.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
# The registry username is derived from github.repository_owner (public — it's in
# the image path), so no REGISTRY_USER secret is needed.
name: CI & Build
on:
push:
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- ".forgejo/workflows/ci.yml"
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
# own group implicitly and are never cancelled.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
jobs:
typecheck:
name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Type check
run: npx vue-tsc --noEmit
working-directory: frontend
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
# ruff is pre-installed in the ci-runner base image.
- name: Lint
run: ruff check src/
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
- name: Create virtual environment
run: uv venv /opt/venv
- name: Install package with dev deps
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Run tests
run: /opt/venv/bin/python -m pytest tests/ -q
build:
name: Build & push image
# Build gates on lint + typecheck. The `test` job runs in parallel for
# visibility but does not block dev image builds (DB-backed integration
# testing happens against the dev image manually, not on every push).
needs: [typecheck, lint]
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Generate image tags and version
id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# NOT bash `[[ ]]`.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# Production line: :latest tracks main's tip (rule 46). No :main tag;
# the :<sha> above is the rollback unit. Version label = short sha.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
run: |
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# Bake the Android client in, on EVERY image build, so :dev, :latest and
# :<version> all carry one and a `docker compose pull` delivers a new client
# along with the new server.
#
# Always the rolling `dev` release — the newest build there is. A versioned
# image therefore carries the newest client rather than one pinned to that
# version; the two negotiate a sync protocol version before linking, so
# "newest" is safe in a way "matching" would not buy anything over.
#
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
# token used inside a build lands in the context or a layer.
#
# NEVER fails the build. An image with no Android client advertises none and
# hides the download — a supported state, and the only one available before
# the first Android build has ever published.
- name: Fetch the Android client to bake in
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
mkdir -p client
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
done
if [ "$ok" = 1 ]; then
echo "Baking in:"
cat client/thoughtsync-android.json
ls -l client/thoughtsync.apk
else
# Both or neither. Half a pair is worse than none: the server would
# read a sidecar describing an APK that isn't there, or an APK it
# cannot state a version for.
echo "::warning::No Android client on the dev release — this image ships without one."
rm -f client/thoughtsync.apk client/thoughtsync-android.json
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Forgejo registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
provenance: false
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true