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.
196 lines
7.1 KiB
YAML
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
|