From a1da517b4e0f6dc154a04d0786d0174b84b1c411 Mon Sep 17 00:00:00 2001 From: mcgeezy Date: Thu, 23 Jul 2026 22:21:41 -0500 Subject: [PATCH] ci: migrate workflows and releases to Gitea --- {.github => .gitea}/workflows/hassfest.yml | 5 +- .gitea/workflows/release.yml | 30 ++++++ {.github => .gitea}/workflows/tests.yml | 3 +- .gitea/workflows/validate.yml | 19 ++++ .github/workflows/release.yml | 26 ----- .github/workflows/validate.yml | 19 ---- README.md | 14 ++- custom_components/relaytv/manifest.json | 4 +- scripts/publish-gitea-release.sh | 113 +++++++++++++++++++++ scripts/validate-repository.py | 69 +++++++++++++ 10 files changed, 249 insertions(+), 53 deletions(-) rename {.github => .gitea}/workflows/hassfest.yml (67%) create mode 100644 .gitea/workflows/release.yml rename {.github => .gitea}/workflows/tests.yml (95%) create mode 100644 .gitea/workflows/validate.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/validate.yml create mode 100644 scripts/publish-gitea-release.sh create mode 100644 scripts/validate-repository.py diff --git a/.github/workflows/hassfest.yml b/.gitea/workflows/hassfest.yml similarity index 67% rename from .github/workflows/hassfest.yml rename to .gitea/workflows/hassfest.yml index f948d27..9144161 100644 --- a/.github/workflows/hassfest.yml +++ b/.gitea/workflows/hassfest.yml @@ -5,7 +5,8 @@ on: pull_request: workflow_dispatch: -permissions: {} +permissions: + contents: read jobs: hassfest: @@ -15,4 +16,4 @@ jobs: uses: actions/checkout@v4 - name: Hassfest validation - uses: home-assistant/actions/hassfest@master + uses: https://github.com/home-assistant/actions/hassfest@master diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..c2b4feb --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + releases: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create relaytv.zip + run: | + cd custom_components/relaytv + zip -r ../../relaytv.zip . -x "__pycache__/*" "*/__pycache__/*" "*.pyc" + + - name: Create Gitea release + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: bash scripts/publish-gitea-release.sh relaytv.zip RELEASE_NOTES.md diff --git a/.github/workflows/tests.yml b/.gitea/workflows/tests.yml similarity index 95% rename from .github/workflows/tests.yml rename to .gitea/workflows/tests.yml index f4adbf6..5a7adc2 100644 --- a/.github/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -5,7 +5,8 @@ on: pull_request: workflow_dispatch: -permissions: {} +permissions: + contents: read jobs: test: diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..aed8a80 --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -0,0 +1,19 @@ +name: Validate + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-repository: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate integration repository structure + run: python3 scripts/validate-repository.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 5cff0d0..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Create relaytv.zip for HACS - run: | - cd custom_components/relaytv - zip -r ../../relaytv.zip . -x "__pycache__/*" "*/__pycache__/*" "*.pyc" - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - body_path: RELEASE_NOTES.md - files: relaytv.zip diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index e3441bc..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Validate - -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -permissions: {} - -jobs: - validate-hacs: - runs-on: ubuntu-latest - steps: - - name: HACS validation - uses: hacs/action@main - with: - category: integration diff --git a/README.md b/README.md index 31a5ad8..bb49dab 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- Tests status + Tests status HACS custom repository Home Assistant 2026.7 or newer GPL-3.0-only license @@ -79,14 +79,22 @@ poll as the reconnect fallback. ### HACS +> HACS currently supports only public GitHub repositories; it cannot install +> directly from this Gitea repository. Keep the +> [`mcgeezy/relaytv-ha`](https://github.com/mcgeezy/relaytv-ha) mirror and its +> releases current if you want to continue distributing through HACS. + 1. Open **HACS** in Home Assistant. -2. Add this repository as a custom repository with category **Integration**. +2. Add `https://github.com/mcgeezy/relaytv-ha` as a custom repository with + category **Integration**. 3. Install **RelayTV** and restart Home Assistant. 4. Add the integration from **Settings → Devices & Services → Add Integration**. ### Manual -1. Copy `custom_components/relaytv` into your Home Assistant config: +1. Download and extract `relaytv.zip` from the Gitea release, or copy + `custom_components/relaytv` from the repository, into your Home Assistant + config: ```text /config/custom_components/relaytv/ diff --git a/custom_components/relaytv/manifest.json b/custom_components/relaytv/manifest.json index 6c2fa12..70453cf 100644 --- a/custom_components/relaytv/manifest.json +++ b/custom_components/relaytv/manifest.json @@ -8,9 +8,9 @@ "dependencies": [ "frontend" ], - "documentation": "https://github.com/mcgeezy/relaytv-ha", + "documentation": "https://git.relaytv.app/RelayTV/RelayTV-HA", "iot_class": "local_polling", - "issue_tracker": "https://github.com/mcgeezy/relaytv-ha/issues", + "issue_tracker": "https://git.relaytv.app/RelayTV/RelayTV-HA/issues", "requirements": [], "version": "0.4.0" } diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh new file mode 100644 index 0000000..219bdf1 --- /dev/null +++ b/scripts/publish-gitea-release.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +archive_path="${1:-relaytv.zip}" +notes_path="${2:-RELEASE_NOTES.md}" + +for variable_name in GITEA_SERVER_URL GITEA_REPOSITORY GITEA_REF_NAME GITEA_TOKEN; do + if [[ -z "${!variable_name:-}" ]]; then + echo "Required environment variable is not set: ${variable_name}" >&2 + exit 1 + fi +done + +if [[ ! -f "$archive_path" ]]; then + echo "Release archive does not exist: $archive_path" >&2 + exit 1 +fi + +if [[ ! -f "$notes_path" ]]; then + echo "Release notes do not exist: $notes_path" >&2 + exit 1 +fi + +api_base="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}/releases" +asset_name="${archive_path##*/}" +authorization_header="Authorization: token ${GITEA_TOKEN}" + +release_payload="$({ + RELEASE_NOTES_PATH="$notes_path" python3 - <<'PY' +import json +import os +from pathlib import Path + +tag = os.environ["GITEA_REF_NAME"] +notes = Path(os.environ["RELEASE_NOTES_PATH"]).read_text(encoding="utf-8") +print(json.dumps({ + "body": notes, + "draft": False, + "name": tag, + "prerelease": False, + "tag_name": tag, +})) +PY +})" + +lookup_response="$( + curl --silent --show-error \ + --header "$authorization_header" \ + --output - \ + --write-out $'\n%{http_code}' \ + "$api_base/tags/$GITEA_REF_NAME" +)" +lookup_status="${lookup_response##*$'\n'}" +lookup_body="${lookup_response%$'\n'*}" + +case "$lookup_status" in + 200) + release_id="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])' <<<"$lookup_body")" + curl --fail-with-body --silent --show-error \ + --request PATCH \ + --header "$authorization_header" \ + --header "Content-Type: application/json" \ + --data "$release_payload" \ + "$api_base/$release_id" >/dev/null + ;; + 404) + release_response="$( + curl --fail-with-body --silent --show-error \ + --request POST \ + --header "$authorization_header" \ + --header "Content-Type: application/json" \ + --data "$release_payload" \ + "$api_base" + )" + release_id="$(python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])' <<<"$release_response")" + ;; + *) + echo "Could not look up Gitea release for $GITEA_REF_NAME (HTTP $lookup_status)." >&2 + echo "$lookup_body" >&2 + exit 1 + ;; +esac + +asset_id="$({ + ASSET_NAME="$asset_name" python3 -c ' +import json +import os +import sys + +assets = json.load(sys.stdin) +match = next((asset for asset in assets if asset["name"] == os.environ["ASSET_NAME"]), None) +print(match["id"] if match else "") +' < <( + curl --fail-with-body --silent --show-error \ + --header "$authorization_header" \ + "$api_base/$release_id/assets" + ) +})" + +if [[ -n "$asset_id" ]]; then + curl --fail-with-body --silent --show-error \ + --request DELETE \ + --header "$authorization_header" \ + "$api_base/$release_id/assets/$asset_id" >/dev/null +fi + +curl --fail-with-body --silent --show-error \ + --request POST \ + --header "$authorization_header" \ + --form "attachment=@${archive_path};filename=${asset_name}" \ + "$api_base/$release_id/assets?name=$asset_name" >/dev/null + +echo "Published $asset_name to Gitea release $GITEA_REF_NAME." diff --git a/scripts/validate-repository.py b/scripts/validate-repository.py new file mode 100644 index 0000000..1d1dc38 --- /dev/null +++ b/scripts/validate-repository.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Validate the local repository structure used to package the integration.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +CUSTOM_COMPONENTS = REPOSITORY_ROOT / "custom_components" + + +def load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"Could not read valid JSON from {path.relative_to(REPOSITORY_ROOT)}: {error}") from error + + if not isinstance(value, dict): + raise ValueError(f"Expected a JSON object in {path.relative_to(REPOSITORY_ROOT)}") + return value + + +def main() -> None: + integration_directories = sorted( + path for path in CUSTOM_COMPONENTS.iterdir() if path.is_dir() and path.name != "__pycache__" + ) + if len(integration_directories) != 1: + names = ", ".join(path.name for path in integration_directories) or "none" + raise ValueError(f"Expected exactly one integration under custom_components; found: {names}") + + integration_directory = integration_directories[0] + manifest = load_json(integration_directory / "manifest.json") + required_manifest_fields = { + "codeowners", + "documentation", + "domain", + "issue_tracker", + "name", + "version", + } + missing_fields = sorted(required_manifest_fields - manifest.keys()) + if missing_fields: + raise ValueError(f"manifest.json is missing required fields: {', '.join(missing_fields)}") + if manifest["domain"] != integration_directory.name: + raise ValueError("manifest.json domain must match its custom_components directory") + if not isinstance(manifest["codeowners"], list) or not manifest["codeowners"]: + raise ValueError("manifest.json codeowners must be a non-empty list") + if not (integration_directory / "brand" / "icon.png").is_file(): + raise ValueError("The integration must include brand/icon.png") + + hacs_manifest = load_json(REPOSITORY_ROOT / "hacs.json") + if not isinstance(hacs_manifest.get("name"), str) or not hacs_manifest["name"].strip(): + raise ValueError("hacs.json must include a non-empty name") + if hacs_manifest.get("zip_release") is True: + filename = hacs_manifest.get("filename") + if not isinstance(filename, str) or not filename.endswith(".zip"): + raise ValueError("hacs.json zip_release requires a .zip filename") + + if not (REPOSITORY_ROOT / "README.md").is_file(): + raise ValueError("The repository must include README.md") + + print(f"Repository structure is valid for integration {manifest['domain']} {manifest['version']}.") + + +if __name__ == "__main__": + main()