feat: Gitea Actions CI/CD, Docker registry images, drive drag-and-drop
Build & Release / Docker / backend (push) Failing after 1m31s
Build & Release / Docker / frontend (push) Failing after 1m42s
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled
Build & Release / Desktop / Linux & Windows (push) Has been cancelled

- Add .gitea/workflows/docker-build.yml: builds backend and frontend
  Docker images on every push, pushes to Gitea Container Registry
  (git.necloud.us/nate/cloudlink/{backend,frontend}:latest + SHA tag)
- Add desktop build job: Linux AppImage and Windows NSIS installer via
  Wine cross-compilation, uploaded as workflow artifacts
- Add release job: creates a Gitea release with installers attached when
  a v* tag is pushed
- Add deploy job: SSH pull + restart on pushes to master
- docker-compose.yml: add image: fields alongside build: so the server
  can pull pre-built images with docker compose pull
- DrivePage: add drag-and-drop upload with full-screen drop overlay;
  refactor upload logic into a shared uploadFile callback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 10:41:39 -04:00
1 parent 9b57dd9b94
commit 2823b8e10e
3 files changed
+213 -7

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
name: Build & Release
on:
push:
branches: [master, main]
tags: ['v*']
pull_request:
branches: [master, main]
env:
REGISTRY: git.necloud.us
OWNER: nate
REPO: cloudlink
jobs:
# ─── Docker: Backend ────────────────────────────────────────────────────────
build-backend:
name: Docker / backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/backend:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/backend:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/backend:cache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/backend:cache,mode=max
# ─── Docker: Frontend ───────────────────────────────────────────────────────
build-frontend:
name: Docker / frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
build-args: |
VITE_API_URL=/api
VITE_WS_URL=/ws
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/frontend:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/frontend:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/frontend:cache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}/frontend:cache,mode=max
# ─── Desktop installers (Linux + Windows, built on Linux runner) ─────────────
build-desktop:
name: Desktop / Linux & Windows
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: desktop/package-lock.json
- name: Install desktop dependencies
working-directory: desktop
run: npm ci
- name: Install Wine (Windows cross-compile)
run: |
sudo dpkg --add-architecture i386
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends wine wine32 wine64
- name: Build Linux AppImage
working-directory: desktop
env:
# electron-builder reads this to avoid interactive prompts
GH_TOKEN: unused
run: npx electron-builder --linux AppImage --x64
- name: Build Windows NSIS installer
working-directory: desktop
env:
GH_TOKEN: unused
run: npx electron-builder --win nsis --x64
- uses: actions/upload-artifact@v4
with:
name: desktop-linux-windows-${{ github.sha }}
path: |
desktop/dist/*.AppImage
desktop/dist/*.exe
if-no-files-found: warn
retention-days: 30
# ─── Deploy (on push to master only, not PRs or tags) ───────────────────────
deploy:
name: Deploy to server
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')
steps:
- name: SSH deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.necloud.us -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose pull backend frontend
docker compose up -d --no-build backend frontend
docker logout git.necloud.us
docker image prune -f
# ─── Release (only on v* tags) ───────────────────────────────────────────────
release:
name: Create release
runs-on: ubuntu-latest
needs: [build-desktop, build-backend, build-frontend]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download desktop artifacts
uses: actions/download-artifact@v4
with:
name: desktop-linux-windows-${{ github.sha }}
path: ./release-assets
- name: Publish Gitea release
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
TAG: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
# Create the release
RELEASE=$(curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.necloud.us/api/v1/repos/nate/cloudlink/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"CloudLink $TAG\",\"body\":\"Docker images pushed to registry. Desktop installers attached below.\"}")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Release ID: $RELEASE_ID"
# Upload each installer as a release asset
for file in ./release-assets/*; do
NAME=$(basename "$file")
echo "Uploading $NAME..."
curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
"https://git.necloud.us/api/v1/repos/nate/cloudlink/releases/$RELEASE_ID/assets?name=$NAME" \
--data-binary @"$file"
done
+2
View File
@@ -20,6 +20,7 @@ services:
retries: 5 retries: 5
backend: backend:
image: git.necloud.us/nate/cloudlink/backend:latest
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -39,6 +40,7 @@ services:
condition: service_healthy condition: service_healthy
frontend: frontend:
image: git.necloud.us/nate/cloudlink/frontend:latest
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
+32 -7
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Upload, File, Trash2, Download, Lock } from 'lucide-react'; import { Upload, File, Trash2, Download, Lock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@@ -34,6 +34,7 @@ export default function DrivePage() {
const qc = useQueryClient(); const qc = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [decryptedNames, setDecryptedNames] = useState<Record<string, string>>({}); const [decryptedNames, setDecryptedNames] = useState<Record<string, string>>({});
const { data: files = [] } = useQuery<DriveFile[]>({ const { data: files = [] } = useQuery<DriveFile[]>({
@@ -60,11 +61,9 @@ export default function DrivePage() {
})(); })();
}, [files, masterKey]); }, [files, masterKey]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { const uploadFile = useCallback(async (file: File) => {
const file = e.target.files?.[0]; if (!masterKey) return;
if (!file || !masterKey) return;
setUploading(true); setUploading(true);
try { try {
const fileKey = await generateFileKey(); const fileKey = await generateFileKey();
const encryptedBlob = await encryptFile(file, fileKey); const encryptedBlob = await encryptFile(file, fileKey);
@@ -83,6 +82,18 @@ export default function DrivePage() {
setUploading(false); setUploading(false);
if (inputRef.current) inputRef.current.value = ''; if (inputRef.current) inputRef.current.value = '';
} }
}, [masterKey, qc]);
function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) uploadFile(file);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
} }
async function handleDownload(f: DriveFile) { async function handleDownload(f: DriveFile) {
@@ -125,11 +136,25 @@ export default function DrivePage() {
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} /> <input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
</header> </header>
<div className="flex-1 overflow-y-auto p-6"> <div
className="flex-1 overflow-y-auto p-6"
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{dragOver && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 pointer-events-none">
<div className="flex flex-col items-center gap-3 bg-surface-1 border-2 border-dashed border-accent rounded-2xl px-16 py-12">
<Upload className="w-12 h-12 text-accent" />
<p className="text-lg font-semibold">Drop to encrypt &amp; upload</p>
</div>
</div>
)}
{files.length === 0 ? ( {files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500 gap-3"> <div className="flex flex-col items-center justify-center h-48 text-slate-500 gap-3">
<File className="w-10 h-10" /> <File className="w-10 h-10" />
<p>No files yet — upload your first encrypted file</p> <p>No files yet — drag &amp; drop or click Upload</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">