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 @@
-
+
@@ -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()