Merge pull request #141 from basecubedev/dependabot/pip/ruff-0.16.4 #129
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: EMS release | |
| on: | |
| push: | |
| branches: | |
| - main | |
| tags: | |
| - "v*" | |
| schedule: | |
| - cron: "0 3 * * 1" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| packages: write | |
| # Owned cache scopes, disjoint from the feature scopes so a feature build can | |
| # never write a release cache. Bump `-v1` to reset. | |
| env: | |
| EMS_RELEASE_CACHE_SCOPE: ems-release-v1 | |
| ADMIN_RELEASE_CACHE_SCOPE: admin-release-v1 | |
| jobs: | |
| publish-ghcr: | |
| name: EMS release publish | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event_name == 'schedule' || | |
| (github.event_name == 'push' && | |
| (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))) || | |
| (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event_name == 'schedule' && 'main' || github.ref }} | |
| - name: Verify tag commit is on main | |
| if: startsWith(github.ref, 'refs/tags/') | |
| run: | | |
| git fetch origin main | |
| if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then | |
| echo "Tag commit is not contained in main. Refusing to publish." | |
| exit 1 | |
| fi | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.11" | |
| cache: pip | |
| cache-dependency-path: | | |
| requirements.txt | |
| requirements-dev.txt | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install -r requirements.txt | |
| python -m pip install -r requirements-dev.txt | |
| - name: Run Ruff lint | |
| run: | | |
| ruff check . | |
| - name: Compile Python files | |
| run: | | |
| python -m compileall ems dashboard scripts tests emsctl.py ems-solarflow-api-control.py | |
| - name: Validate example Compose file | |
| run: | | |
| docker compose -f docker-compose.example.yml config | |
| - name: Install the filesystem tools the fixtures build with | |
| run: sudo apt-get install -y --no-install-recommends mtools e2fsprogs | |
| - name: Run full test suite (excludes real-Docker e2e, covered by CI workflow) | |
| run: | | |
| pytest -q -m "not docker" tests/ | |
| - name: Run paired System Build Docker/startup contract | |
| run: | | |
| pytest -q -rs tests/test_system_build_docker_contract.py | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve build identity | |
| id: build_identity | |
| run: | | |
| # `latest` is a channel, not a version. Resolve a monotonic build | |
| # identity so Admin can compare a running `latest` image against | |
| # stable/rc tags by image labels, not tag name alone. | |
| if [[ "${GITHUB_REF}" == refs/tags/* ]]; then | |
| release_tag="${GITHUB_REF#refs/tags/}" | |
| # Only a real release tag becomes the runtime ems_version; `latest` | |
| # stays empty so a channel never poses as a release version. | |
| ems_release_tag="${release_tag}" | |
| if [[ "${release_tag}" == *-* ]]; then | |
| channel="rc" # refs/tags/vX.Y.Z-... (pre-release) | |
| else | |
| channel="stable" # refs/tags/vX.Y.Z | |
| fi | |
| else | |
| channel="latest" # main / schedule / workflow_dispatch | |
| release_tag="latest" | |
| ems_release_tag="" | |
| fi | |
| git_commit="$(git rev-parse --verify HEAD)" | |
| if [[ ! "${git_commit}" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "The checked-out release ref did not resolve to a full lowercase Git SHA." >&2 | |
| exit 1 | |
| fi | |
| git_describe="$(git describe --tags --always --dirty 2>/dev/null || true)" | |
| git_commit_short="${git_commit::12}" | |
| if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then | |
| git_dirty="true" | |
| else | |
| git_dirty="false" | |
| fi | |
| { | |
| echo "channel=${channel}" | |
| echo "release_tag=${release_tag}" | |
| echo "ems_release_tag=${ems_release_tag}" | |
| echo "git_describe=${git_describe}" | |
| echo "git_commit=${git_commit}" | |
| echo "git_commit_short=${git_commit_short}" | |
| echo "git_dirty=${git_dirty}" | |
| echo "build_serial=${GITHUB_RUN_NUMBER}" | |
| echo "build_id=${release_tag}-${git_commit_short}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| } >> "${GITHUB_OUTPUT}" | |
| - name: Generate Docker metadata | |
| id: meta | |
| uses: docker/metadata-action@v6 | |
| with: | |
| images: ghcr.io/basecubedev/ems-solarflow-api-control | |
| flavor: | | |
| latest=false | |
| tags: | | |
| type=ref,event=tag | |
| type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} | |
| labels: | | |
| org.opencontainers.image.title=ems-solarflow-api-control | |
| org.opencontainers.image.description=Local EMS controller for Zendure SolarFlow. Docker images include automatic first-run config bootstrap. See README for Quick Start and docs/docker.md for full setup. | |
| org.opencontainers.image.source=https://github.com/basecubedev/ems-solarflow-api-control | |
| org.opencontainers.image.documentation=https://github.com/basecubedev/ems-solarflow-api-control/blob/main/docs/docker.md | |
| org.opencontainers.image.version=${{ steps.build_identity.outputs.release_tag }} | |
| org.opencontainers.image.revision=${{ steps.build_identity.outputs.git_commit }} | |
| de.basecubedev.ems.channel=${{ steps.build_identity.outputs.channel }} | |
| de.basecubedev.ems.build_serial=${{ steps.build_identity.outputs.build_serial }} | |
| de.basecubedev.ems.build_id=${{ steps.build_identity.outputs.build_id }} | |
| de.basecubedev.ems.release_tag=${{ steps.build_identity.outputs.release_tag }} | |
| # Imports the release cache but never exports (the pushed build is the | |
| # authoritative exporter). Explicit labels override the Dockerfile's | |
| # EMS_RELEASE_TAG-derived version label, which is empty on latest. | |
| - name: Build local Docker image for content validation | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| platforms: linux/amd64 | |
| load: true | |
| push: false | |
| tags: ems-solarflow-api-control:ci | |
| build-args: | | |
| EMS_RELEASE_TAG=${{ steps.build_identity.outputs.ems_release_tag }} | |
| EMS_GIT_COMMIT=${{ steps.build_identity.outputs.git_commit }} | |
| EMS_GIT_COMMIT_SHORT=${{ steps.build_identity.outputs.git_commit_short }} | |
| EMS_GIT_DESCRIBE=${{ steps.build_identity.outputs.git_describe }} | |
| EMS_GIT_BRANCH=${{ github.ref_name }} | |
| EMS_GIT_DIRTY=${{ steps.build_identity.outputs.git_dirty }} | |
| EMS_BUILD_ID=${{ steps.build_identity.outputs.build_id }} | |
| EMS_BUILD_SERIAL=${{ steps.build_identity.outputs.build_serial }} | |
| EMS_CHANNEL=${{ steps.build_identity.outputs.channel }} | |
| labels: | | |
| org.opencontainers.image.version=${{ steps.build_identity.outputs.release_tag }} | |
| org.opencontainers.image.revision=${{ steps.build_identity.outputs.git_commit }} | |
| de.basecubedev.ems.channel=${{ steps.build_identity.outputs.channel }} | |
| de.basecubedev.ems.build_id=${{ steps.build_identity.outputs.build_id }} | |
| de.basecubedev.ems.release_tag=${{ steps.build_identity.outputs.release_tag }} | |
| cache-from: type=gha,scope=${{ env.EMS_RELEASE_CACHE_SCOPE }} | |
| - name: Validate Docker image runtime content | |
| run: | | |
| docker run --rm ems-solarflow-api-control:ci python3 emsctl.py --help | |
| docker run --rm ems-solarflow-api-control:ci test -f /app/config.template.json | |
| docker run --rm ems-solarflow-api-control:ci sh -c ' | |
| test -f /app/emsctl.py && | |
| test -f /app/docker-entrypoint.sh && | |
| test -f /app/docs/docker.md && | |
| test -f /app/ems/config.py && | |
| test -f /app/dashboard/server.py && | |
| test ! -e /app/.git && | |
| test ! -e /app/tests && | |
| test ! -e /app/__pycache__ && | |
| test ! -e /app/backup && | |
| test ! -e /app/.venv && | |
| test ! -e /app/deploy/docker/influxdb.env && | |
| test -z "$(find /app/data -mindepth 1 -maxdepth 1 -print -quit)" | |
| ' | |
| # Authoritative EMS release cache exporter. | |
| - name: Build and push Docker image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| platforms: linux/amd64,linux/arm64 | |
| push: true | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| build-args: | | |
| EMS_RELEASE_TAG=${{ steps.build_identity.outputs.ems_release_tag }} | |
| EMS_GIT_COMMIT=${{ steps.build_identity.outputs.git_commit }} | |
| EMS_GIT_COMMIT_SHORT=${{ steps.build_identity.outputs.git_commit_short }} | |
| EMS_GIT_DESCRIBE=${{ steps.build_identity.outputs.git_describe }} | |
| EMS_GIT_BRANCH=${{ github.ref_name }} | |
| EMS_GIT_DIRTY=${{ steps.build_identity.outputs.git_dirty }} | |
| EMS_BUILD_ID=${{ steps.build_identity.outputs.build_id }} | |
| EMS_BUILD_SERIAL=${{ steps.build_identity.outputs.build_serial }} | |
| EMS_CHANNEL=${{ steps.build_identity.outputs.channel }} | |
| cache-from: type=gha,scope=${{ env.EMS_RELEASE_CACHE_SCOPE }} | |
| cache-to: type=gha,mode=max,scope=${{ env.EMS_RELEASE_CACHE_SCOPE }} | |
| annotations: | | |
| index:org.opencontainers.image.description=Local EMS controller for Zendure SolarFlow. Docker images include automatic first-run config bootstrap. See README for Quick Start and docs/docker.md for full setup. | |
| # --- Admin Console image ------------------------------------------------ | |
| # The installable Admin Console ships as its own image so end users can run | |
| # it without a source checkout. It reuses the same build identity as the | |
| # EMS image; only the metadata and Dockerfile differ. | |
| - name: Generate Admin Docker metadata | |
| id: meta_admin | |
| uses: docker/metadata-action@v6 | |
| with: | |
| images: ghcr.io/basecubedev/ems-solarflow-admin | |
| flavor: | | |
| latest=false | |
| tags: | | |
| type=ref,event=tag | |
| type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} | |
| labels: | | |
| org.opencontainers.image.title=ems-solarflow-admin | |
| org.opencontainers.image.description=Browser-based Admin Console for EMS SolarFlow setup, maintenance, updates and backups. | |
| org.opencontainers.image.source=https://github.com/basecubedev/ems-solarflow-api-control | |
| org.opencontainers.image.documentation=https://github.com/basecubedev/ems-solarflow-api-control/blob/main/docs/user/admin-console.md | |
| org.opencontainers.image.version=${{ steps.build_identity.outputs.release_tag }} | |
| org.opencontainers.image.revision=${{ steps.build_identity.outputs.git_commit }} | |
| de.basecubedev.ems.channel=${{ steps.build_identity.outputs.channel }} | |
| de.basecubedev.ems.build_serial=${{ steps.build_identity.outputs.build_serial }} | |
| de.basecubedev.ems.build_id=${{ steps.build_identity.outputs.build_id }} | |
| de.basecubedev.ems.release_tag=${{ steps.build_identity.outputs.release_tag }} | |
| # Imports the Admin release cache but never exports; the Dockerfile derives | |
| # the identity labels from these build args. | |
| - name: Build local Admin image for content validation | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./deploy/admin/Dockerfile | |
| platforms: linux/amd64 | |
| load: true | |
| push: false | |
| tags: ems-solarflow-admin:ci | |
| build-args: | | |
| EMS_REVISION=${{ steps.build_identity.outputs.git_commit }} | |
| EMS_BUILD_ID=${{ steps.build_identity.outputs.build_id }} | |
| EMS_CHANNEL=${{ steps.build_identity.outputs.channel }} | |
| EMS_RELEASE_TAG=${{ steps.build_identity.outputs.release_tag }} | |
| EMS_SYSTEM_TAG=${{ steps.build_identity.outputs.release_tag }} | |
| cache-from: type=gha,scope=${{ env.ADMIN_RELEASE_CACHE_SCOPE }} | |
| - name: Validate Admin image runtime content | |
| run: | | |
| docker run --rm ems-solarflow-admin:ci python -m admin --help | |
| - name: Verify Admin/EMS system-build pair metadata | |
| run: | | |
| # One paired system build: both images' OCI labels, EMS runtime ENV, | |
| # and both embedded descriptors must agree on the complete identity. | |
| # On latest, OCI/descriptors intentionally say "latest" while the EMS | |
| # runtime release version stays empty because latest is a channel. | |
| set -euo pipefail | |
| expected_system_tag="${{ steps.build_identity.outputs.release_tag }}" | |
| expected_channel="${{ steps.build_identity.outputs.channel }}" | |
| expected_rev="${{ steps.build_identity.outputs.git_commit }}" | |
| expected_build_id="${{ steps.build_identity.outputs.build_id }}" | |
| expected_release_tag="${{ steps.build_identity.outputs.release_tag }}" | |
| expected_runtime_release_tag="${{ steps.build_identity.outputs.ems_release_tag }}" | |
| expected_admin_image="ghcr.io/basecubedev/ems-solarflow-admin:${expected_system_tag}" | |
| expected_ems_image="ghcr.io/basecubedev/ems-solarflow-api-control:${expected_system_tag}" | |
| fail() { echo "SYSTEM BUILD PAIR MISMATCH: $1" >&2; exit 1; } | |
| expect() { [ "$1" = "$2" ] || fail "$3=$1 != $2"; } | |
| label() { docker inspect --format "{{ index .Config.Labels \"$1\" }}" "$2"; } | |
| admin_version="$(label org.opencontainers.image.version ems-solarflow-admin:ci)" | |
| admin_rev="$(label org.opencontainers.image.revision ems-solarflow-admin:ci)" | |
| admin_channel="$(label de.basecubedev.ems.channel ems-solarflow-admin:ci)" | |
| admin_build_id="$(label de.basecubedev.ems.build_id ems-solarflow-admin:ci)" | |
| admin_release_tag="$(label de.basecubedev.ems.release_tag ems-solarflow-admin:ci)" | |
| ems_version="$(label org.opencontainers.image.version ems-solarflow-api-control:ci)" | |
| ems_oci_rev="$(label org.opencontainers.image.revision ems-solarflow-api-control:ci)" | |
| ems_oci_channel="$(label de.basecubedev.ems.channel ems-solarflow-api-control:ci)" | |
| ems_oci_build_id="$(label de.basecubedev.ems.build_id ems-solarflow-api-control:ci)" | |
| ems_oci_release_tag="$(label de.basecubedev.ems.release_tag ems-solarflow-api-control:ci)" | |
| sb="$(docker run --rm ems-solarflow-admin:ci cat /app/release-resources/system-build.json)" | |
| rm="$(docker run --rm ems-solarflow-admin:ci cat /app/release-resources/resource-manifest.json)" | |
| jq_get() { printf '%s' "$1" | python3 -c "import sys,json;print(json.load(sys.stdin)$2)"; } | |
| sb_format_version="$(jq_get "$sb" "['format_version']")" | |
| sb_system_tag="$(jq_get "$sb" "['system_tag']")" | |
| sb_channel="$(jq_get "$sb" "['channel']")" | |
| sb_rev="$(jq_get "$sb" "['revision']")" | |
| sb_build_id="$(jq_get "$sb" "['build_id']")" | |
| sb_release_tag="$(jq_get "$sb" "['release_tag']")" | |
| sb_admin_image="$(jq_get "$sb" "['admin_image']")" | |
| sb_ems_image="$(jq_get "$sb" "['ems_image']")" | |
| rm_format_version="$(jq_get "$rm" "['format_version']")" | |
| rm_system_tag="$(jq_get "$rm" "['system_tag']")" | |
| rm_channel="$(jq_get "$rm" "['channel']")" | |
| rm_rev="$(jq_get "$rm" "['revision']")" | |
| rm_build_id="$(jq_get "$rm" "['build_id']")" | |
| rm_release_tag="$(jq_get "$rm" "['release_tag']")" | |
| rm_admin_image="$(jq_get "$rm" "['admin_image']")" | |
| rm_ems_image="$(jq_get "$rm" "['ems_image']")" | |
| ems_rev="$(docker run --rm ems-solarflow-api-control:ci printenv EMS_GIT_COMMIT)" | |
| ems_build_id="$(docker run --rm ems-solarflow-api-control:ci printenv EMS_BUILD_ID)" | |
| ems_channel="$(docker run --rm ems-solarflow-api-control:ci printenv EMS_CHANNEL)" | |
| ems_release_tag="$(docker run --rm ems-solarflow-api-control:ci printenv EMS_RELEASE_TAG)" | |
| for name in admin_version ems_version sb_system_tag rm_system_tag; do | |
| expect "${!name}" "$expected_system_tag" "$name" | |
| done | |
| for name in admin_rev ems_oci_rev ems_rev sb_rev rm_rev; do | |
| expect "${!name}" "$expected_rev" "$name" | |
| done | |
| for name in admin_channel ems_oci_channel ems_channel sb_channel rm_channel; do | |
| expect "${!name}" "$expected_channel" "$name" | |
| done | |
| for name in admin_build_id ems_oci_build_id ems_build_id sb_build_id rm_build_id; do | |
| expect "${!name}" "$expected_build_id" "$name" | |
| done | |
| for name in admin_release_tag ems_oci_release_tag sb_release_tag rm_release_tag; do | |
| expect "${!name}" "$expected_release_tag" "$name" | |
| done | |
| for name in sb_admin_image rm_admin_image; do | |
| expect "${!name}" "$expected_admin_image" "$name" | |
| done | |
| for name in sb_ems_image rm_ems_image; do | |
| expect "${!name}" "$expected_ems_image" "$name" | |
| done | |
| expect "$ems_release_tag" "$expected_runtime_release_tag" "EMS runtime release_tag" | |
| expect "$sb_format_version" "1" "system-build format_version" | |
| expect "$rm_format_version" "1" "resource-manifest format_version" | |
| required_resources=( | |
| config.template.json | |
| docker-compose.example.yml | |
| install-docker.sh | |
| install-docker.ps1 | |
| ) | |
| while IFS= read -r resource; do | |
| required_resources+=("$resource") | |
| done < <(find deploy/docker -type f -print | sort) | |
| for resource in "${required_resources[@]}"; do | |
| resource_hash="$(printf '%s' "$rm" | RESOURCE="$resource" python3 -c \ | |
| 'import json,os,sys; print(json.load(sys.stdin)["files"].get(os.environ["RESOURCE"], ""))')" | |
| [[ "$resource_hash" =~ ^sha256:[0-9a-f]{64}$ ]] \ | |
| || fail "resource-manifest hash missing or invalid for $resource" | |
| actual_hash="$(docker run --rm ems-solarflow-admin:ci python -c \ | |
| 'import hashlib,sys; print("sha256:" + hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' \ | |
| "/app/release-resources/$resource")" | |
| expect "$actual_hash" "$resource_hash" "embedded resource hash ($resource)" | |
| done | |
| echo "Admin/EMS system-build pair verified: tag=$expected_system_tag rev=$expected_rev build_id=$expected_build_id channel=$expected_channel" | |
| # Authoritative Admin release cache exporter (separate scope from EMS). | |
| - name: Build and push Admin Docker image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./deploy/admin/Dockerfile | |
| platforms: linux/amd64,linux/arm64 | |
| push: true | |
| tags: ${{ steps.meta_admin.outputs.tags }} | |
| labels: ${{ steps.meta_admin.outputs.labels }} | |
| build-args: | | |
| EMS_REVISION=${{ steps.build_identity.outputs.git_commit }} | |
| EMS_BUILD_ID=${{ steps.build_identity.outputs.build_id }} | |
| EMS_CHANNEL=${{ steps.build_identity.outputs.channel }} | |
| EMS_RELEASE_TAG=${{ steps.build_identity.outputs.release_tag }} | |
| EMS_SYSTEM_TAG=${{ steps.build_identity.outputs.release_tag }} | |
| cache-from: type=gha,scope=${{ env.ADMIN_RELEASE_CACHE_SCOPE }} | |
| cache-to: type=gha,mode=max,scope=${{ env.ADMIN_RELEASE_CACHE_SCOPE }} | |
| annotations: | | |
| index:org.opencontainers.image.description=Browser-based Admin Console for EMS SolarFlow setup, maintenance, updates and backups. |