From 832aa86a34290f3a4791c3ab7b89d4f297a16f8c Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 3 Apr 2026 16:38:56 -0700 Subject: [PATCH] Add Gitea CI and release pipeline --- .gitea/workflows/ci.yml | 131 +++++++++++++++++++++++++++++++++++++++ scripts/gitea_release.py | 95 ++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 scripts/gitea_release.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..589fa38 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,131 @@ +name: ci + +on: + push: + branches: + - main + tags: + - "v*" + - "release-*" + - "[0-9]+.[0-9]+.[0-9]+*" + +permissions: + contents: write + +env: + GO_VERSION: "1.26.0" + ANDROID_SDK_ROOT: /opt/android-sdk + ANDROID_NDK_ROOT: /opt/android-ndk + JAVA_HOME: /usr/lib/jvm/java-25-openjdk + DIST_DIR: dist + +jobs: + lint-test: + runs-on: + - self-hosted + - linux + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Lint + shell: bash + run: | + set -euo pipefail + state_dir="$(mktemp -d)" + trap 'rm -rf -- "$state_dir"' EXIT + KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run + + - name: Test + shell: bash + run: | + set -euo pipefail + state_dir="$(mktemp -d)" + trap 'rm -rf -- "$state_dir"' EXIT + KEEPASSGO_STATE_DIR="$state_dir" go test ./... + + build: + needs: lint-test + runs-on: + - self-hosted + - linux + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Prepare dist directory + shell: bash + run: | + set -euo pipefail + mkdir -p "${DIST_DIR}" + + - name: Build desktop binaries + shell: bash + run: | + set -euo pipefail + for target in \ + "linux amd64" \ + "linux arm64" \ + "windows amd64" \ + "windows arm64" + do + set -- ${target} + goos="$1" + goarch="$2" + ext="" + if [[ "${goos}" == "windows" ]]; then + ext=".exe" + fi + out="${DIST_DIR}/keepassgo-${goos}-${goarch}${ext}" + GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -o "${out}" . + done + + - name: Build APK + shell: bash + run: | + set -euo pipefail + make apk + cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk" + + - name: Upload CI artifacts + uses: christopherhx/gitea-upload-artifact@v4 + with: + name: keepassgo-${{ gitea.sha }} + path: | + dist/keepassgo-linux-amd64 + dist/keepassgo-linux-arm64 + dist/keepassgo-windows-amd64.exe + dist/keepassgo-windows-arm64.exe + dist/keepassgo.apk + retention-days: 30 + + - name: Publish release artifacts + if: startsWith(gitea.ref, 'refs/tags/') + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_REF_NAME: ${{ gitea.ref_name }} + shell: bash + run: | + set -euo pipefail + python3 scripts/gitea_release.py \ + --server "${GITEA_SERVER_URL}" \ + --repo "${GITEA_REPOSITORY}" \ + --token "${GITEA_TOKEN}" \ + --tag "${GITEA_REF_NAME}" \ + "${DIST_DIR}/keepassgo-linux-amd64" \ + "${DIST_DIR}/keepassgo-linux-arm64" \ + "${DIST_DIR}/keepassgo-windows-amd64.exe" \ + "${DIST_DIR}/keepassgo-windows-arm64.exe" \ + "${DIST_DIR}/keepassgo.apk" diff --git a/scripts/gitea_release.py b/scripts/gitea_release.py new file mode 100644 index 0000000..48e1e6c --- /dev/null +++ b/scripts/gitea_release.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +import argparse +import json +import mimetypes +import pathlib +import sys +import urllib.error +import urllib.parse +import urllib.request + + +def request_json(method: str, url: str, token: str, payload: dict | None = None) -> dict: + data = None + headers = {"Authorization": f"token {token}", "Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + + +def request_no_content(method: str, url: str, token: str) -> None: + req = urllib.request.Request( + url, + headers={"Authorization": f"token {token}", "Accept": "application/json"}, + method=method, + ) + with urllib.request.urlopen(req): + return + + +def get_or_create_release(server: str, repo: str, token: str, tag: str) -> dict: + base = f"{server.rstrip('/')}/api/v1/repos/{repo}" + tag_url = f"{base}/releases/tags/{urllib.parse.quote(tag, safe='')}" + try: + return request_json("GET", tag_url, token) + except urllib.error.HTTPError as err: + if err.code != 404: + raise + payload = { + "tag_name": tag, + "name": tag, + "draft": False, + "prerelease": False, + } + return request_json("POST", f"{base}/releases", token, payload) + + +def upload_asset(server: str, repo: str, token: str, release: dict, path: pathlib.Path) -> None: + base = f"{server.rstrip('/')}/api/v1/repos/{repo}" + assets = release.get("assets", []) + for asset in assets: + if asset.get("name") == path.name: + request_no_content("DELETE", f"{base}/releases/{release['id']}/assets/{asset['id']}", token) + query = urllib.parse.urlencode({"name": path.name}) + url = f"{base}/releases/{release['id']}/assets?{query}" + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + req = urllib.request.Request( + url, + data=path.read_bytes(), + headers={ + "Authorization": f"token {token}", + "Accept": "application/json", + "Content-Type": content_type, + }, + method="POST", + ) + with urllib.request.urlopen(req): + return + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--server", required=True) + parser.add_argument("--repo", required=True) + parser.add_argument("--token", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("artifacts", nargs="+") + args = parser.parse_args() + + paths = [pathlib.Path(p) for p in args.artifacts] + missing = [str(p) for p in paths if not p.is_file()] + if missing: + print(f"missing artifacts: {', '.join(missing)}", file=sys.stderr) + return 1 + + release = get_or_create_release(args.server, args.repo, args.token, args.tag) + for path in paths: + upload_asset(args.server, args.repo, args.token, release, path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())