Merge pull request 'fix(gitea-actions): serve CI images from the Gitea registry' (#12) from feat/ci-images-registry into master
CI Images / Plan (push) Failing after 1s
CI Images / Build ${{ matrix.key }} (push) Skipped

This commit was merged in pull request #12.
This commit is contained in:
2026-09-28 12:19:57 -04:00
20 changed files with 807 additions and 495 deletions
+242
View File
@@ -0,0 +1,242 @@
---
# Builds the Gitea Actions job images and publishes them to the Gitea container
# registry, so the runner can re-pull one the nightly podman prune removed
# instead of waiting for a human to re-run `make deploy TAGS=gitea-actions`.
#
# Source of truth is ansible/roles/gitea-actions: files/Containerfile.* for the
# image contents, defaults/main.yml for the version pins and the registry path.
# This workflow reads those vars rather than repeating them. roles/gitea-actions
# then only pulls what lands here (gitea_ci_build_local is the escape hatch for
# seeding an empty namespace, since the job below runs *in* gitea-ci).
#
# `docker build` here talks to the gitea-runner user's rootless podman socket,
# mounted into every job container by roles/gitea-actions (config.yaml.j2), so
# the build happens in the same image store the runner pulls from and the layer
# cache survives between runs. That also means a build writes tags the live
# runner will use -- which is why pull requests build under a throwaway
# :pr-<n> tag and delete it again.
name: CI Images
on:
push:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
pull_request:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
workflow_dispatch:
inputs:
image:
description: Which image to rebuild
type: choice
options: [all, ci, espidf, platformio]
default: all
schedule:
# Weekly rebuild so base-image security updates land without a commit.
# Sunday 04:00, after the 02:00 podman prune has finished.
- cron: "0 4 * * 0"
env:
DEFAULTS: ansible/roles/gitea-actions/defaults/main.yml
CONTEXT: ansible/roles/gitea-actions/files
REGISTRY: git.debyl.io
# Not a secret: the same namespace is in defaults/main.yml. It must be the
# owner of REGISTRY_TOKEN -- Gitea authorises a package push by the token's
# user, not by the path, so pushing to gitbot/ means logging in as gitbot.
REGISTRY_USER: gitbot
KEEP_LABEL: io.debyl.ci-base
# One publisher at a time. Two runs pushing :latest concurrently would leave the
# registry holding whichever finished last, which need not be the newest commit.
concurrency:
group: ci-images
cancel-in-progress: false
jobs:
plan:
name: Plan
runs-on: fedora
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
any: ${{ steps.plan.outputs.any }}
steps:
- uses: actions/checkout@v4
with:
# Full history so the change detection below can diff against the
# pushed-from commit / the PR base.
fetch-depth: 0
- name: Decide which images to build
id: plan
env:
EVENT: ${{ github.event_name }}
SELECTED: ${{ github.event.inputs.image }}
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, subprocess, sys, yaml
defaults = yaml.safe_load(open(os.environ["DEFAULTS"]))
ctx = os.environ["CONTEXT"]
reg, ns = os.environ["REGISTRY"], os.environ["REGISTRY_USER"]
# Mirrors gitea_ci_images in defaults/main.yml. The tags are rebuilt
# from the same version vars the role interpolates, so a pin bump in
# that file moves the image tag here and in ansible together.
images = [
{
"key": "ci",
"containerfile": "Containerfile.ci",
"tag": f"{reg}/{ns}/gitea-ci:latest",
"build_args": "",
},
{
"key": "espidf",
"containerfile": "Containerfile.espidf",
"tag": f"{reg}/{ns}/gitea-ci-espidf:{defaults['esp_idf_version']}",
"build_args": f"ESP_IDF_VERSION={defaults['esp_idf_version']}",
},
{
"key": "platformio",
"containerfile": "Containerfile.platformio",
"tag": f"{reg}/{ns}/gitea-ci-platformio:{defaults['pio_espressif32_version']}",
"build_args": (
f"PLATFORMIO_CORE_VERSION={defaults['platformio_core_version']} "
f"PIO_ESPRESSIF32_VERSION={defaults['pio_espressif32_version']}"
),
},
]
event = os.environ["EVENT"]
def changed_files(base):
"""Paths touched since `base`, or None if the diff is not usable."""
if not base or set(base) == {"0"}:
return None
try:
out = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout
except subprocess.CalledProcessError:
# Force push, shallow clone, first push of a branch: fall back
# to building everything rather than silently skipping a real
# change.
return None
return set(out.split())
if event == "workflow_dispatch":
selected = os.environ.get("SELECTED") or "all"
picked = images if selected == "all" else [i for i in images if i["key"] == selected]
elif event == "schedule":
picked = images
else:
base = os.environ["PR_BASE"] if event == "pull_request" else os.environ["BEFORE"]
touched = changed_files(base)
if touched is None:
picked = images
else:
# defaults/main.yml holds every pin, so a change there could
# retag any image; the workflow file itself changes how all of
# them are built. Either one rebuilds the lot.
wide = {os.environ["DEFAULTS"], ".gitea/workflows/ci-images.yml"}
if touched & wide:
picked = images
else:
picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched]
print(f"matrix={json.dumps({'include': picked})}")
print(f"any={'true' if picked else 'false'}")
print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr)
PY
build:
name: Build ${{ matrix.key }}
needs: plan
if: needs.plan.outputs.any == 'true'
runs-on: fedora
strategy:
# One image failing must not cancel the others: they are independent, and
# a half-published set is what this whole workflow exists to avoid.
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
# The build lands in the live runner's image store, and act_runner will
# not re-pull a tag it already has locally. Tagging a PR build with the
# real tag would therefore hand every later job on this host an unmerged
# image, so PRs get a throwaway tag that the cleanup step removes.
- name: Resolve build tag
id: tag
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "image=${{ matrix.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "image=${{ matrix.tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Build ${{ matrix.key }}
env:
IMAGE: ${{ steps.tag.outputs.image }}
BUILD_ARGS: ${{ matrix.build_args }}
run: |
set -euo pipefail
args=()
for a in $BUILD_ARGS; do args+=(--build-arg "$a"); done
# --pull so a scheduled run actually picks up a refreshed base image;
# without it an unchanged FROM line just hits the local layer cache.
docker build --pull \
"${args[@]}" \
-t "$IMAGE" \
-f "$CONTEXT/${{ matrix.containerfile }}" \
"$CONTEXT"
- name: Verify the prune-exemption label survived the build
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
# roles/podman's nightly prune keeps an image only if it carries this
# label (podman_prune_ci_keep_label). Publishing one without it would
# quietly restore the nightly-deletion behaviour this replaced, and
# nothing would notice until CI failed on a Monday morning.
got=$(docker inspect -f "{{ index .Config.Labels \"$KEEP_LABEL\" }}" "$IMAGE")
test "$got" = "true" || {
echo "::error::$IMAGE is missing LABEL $KEEP_LABEL=true"
exit 1
}
- name: Push ${{ matrix.key }}
if: github.event_name != 'pull_request'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
docker push "$IMAGE"
echo "Pushed: $IMAGE"
# Always, including on failure: the throwaway tag carries the keep label,
# so the nightly prune will not reclaim it and a few skipped cleanups add
# up to gigabytes in the runner's store.
- name: Drop the pull-request image
if: always() && github.event_name == 'pull_request'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: docker rmi -f "$IMAGE" || true
+18
View File
@@ -28,6 +28,11 @@ The project uses Python virtualenv for dependency management:
- Makefile automatically creates `.venv/` and installs dependencies - Makefile automatically creates `.venv/` and installs dependencies
- Vault password is sourced from password manager via `.pass.sh` - Vault password is sourced from password manager via `.pass.sh`
### Git Workflow
- Work directly on `master` - no feature branches and no pull requests in this repo.
- Commit to `master` and push to `origin/master` when asked; don't create a branch first.
- Pushing to `master` also triggers `.gitea/workflows/ci-images.yml` (see Gitea Actions CI images below), which only rebuilds images whose inputs changed.
## Architecture ## Architecture
### Directory Structure ### Directory Structure
@@ -45,6 +50,7 @@ ansible/
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically) │ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
│ ├── github-actions/# CI/CD runner setup │ ├── github-actions/# CI/CD runner setup
│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL) │ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL)
│ ├── gitea-actions/ # Gitea Actions runners + CI job images (see its README)
│ └── pihole/ # DNS filtering │ └── pihole/ # DNS filtering
└── vars/ └── vars/
└── vault.yml # Encrypted secrets └── vault.yml # Encrypted secrets
@@ -90,6 +96,7 @@ Tasks are tagged by service/component for selective deployment:
- `ddns` - Dynamic DNS tasks - `ddns` - Dynamic DNS tasks
- ~~`drone` - CI/CD tasks (decommissioned)~~ - ~~`drone` - CI/CD tasks (decommissioned)~~
- `hass` - Home Assistant tasks - `hass` - Home Assistant tasks
- `gitea-actions` - Gitea Actions runners and their CI job images
- Common infrastructure tags like `common`, `ssl` - Common infrastructure tags like `common`, `ssl`
## Configuration Files ## Configuration Files
@@ -122,6 +129,17 @@ Tasks are tagged by service/component for selective deployment:
- Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is - Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is
unreachable unreachable
### Gitea Actions CI images
The runner's job images (`gitea-ci`, `gitea-ci-espidf`, `gitea-ci-platformio`)
are built by `.gitea/workflows/ci-images.yml` and published to the Gitea
container registry under `git.debyl.io/gitbot/`. The `gitea-actions` role only
pulls them - do NOT add build steps back to it. To change an image, edit
`ansible/roles/gitea-actions/files/Containerfile.*` (or a version pin in that
role's `defaults/main.yml`) and push to master; CI rebuilds only what changed.
See `ansible/roles/gitea-actions/README.md` for the registry rationale, the
prune-exemption label, and the bootstrap path when CI itself cannot build.
### Remote SSH Commands for Service Users ### Remote SSH Commands for Service Users
The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH: The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
+3 -1
View File
@@ -54,7 +54,9 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE}
touch $@ touch $@
# Linting # Linting
YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*') # .gitea/workflows is linted too: the CI-image workflow is as much part of the
# deployment as the roles it publishes for.
YAML_FILES=$(shell find ansible/ .gitea/ -name '*.yml' -not -name '*vault*')
SKIP_FILE=./.lint-vars.sh SKIP_FILE=./.lint-vars.sh
# Targets # Targets
+3 -1
View File
@@ -4,9 +4,11 @@ git_home: "/srv/{{ git_user }}"
# Gitea configuration # Gitea configuration
gitea_debyl_server_name: git.debyl.io gitea_debyl_server_name: git.debyl.io
gitea_image: docker.gitea.com/gitea:1.26.1 # Pinned per instance so one can be upgraded (and verified) before the other.
gitea_debyl_image: docker.gitea.com/gitea:1.27.3
gitea_db_image: docker.io/library/postgres:14-alpine gitea_db_image: docker.io/library/postgres:14-alpine
# Skudak Gitea configuration # Skudak Gitea configuration
gitea_skudak_server_name: git.skudak.com gitea_skudak_server_name: git.skudak.com
gitea_skudak_ssh_port: 2222 gitea_skudak_ssh_port: 2222
gitea_skudak_image: docker.gitea.com/gitea:1.27.3
+1 -1
View File
@@ -43,7 +43,7 @@
become_user: "{{ git_user }}" become_user: "{{ git_user }}"
containers.podman.podman_container: containers.podman.podman_container:
name: gitea-skudak name: gitea-skudak
image: "{{ gitea_image }}" image: "{{ gitea_skudak_image }}"
pod: gitea-skudak-pod pod: gitea-skudak-pod
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
+6 -6
View File
@@ -10,7 +10,7 @@
state: started state: started
ports: ports:
- "3100:3000" - "3100:3000"
tags: gitea tags: gitea, gitea-debyl
# PostgreSQL container in pod # PostgreSQL container in pod
- name: create gitea-debyl-postgres container - name: create gitea-debyl-postgres container
@@ -28,7 +28,7 @@
POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}" POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}"
volumes: volumes:
- "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data" - "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data"
tags: gitea tags: gitea, gitea-debyl
# Gitea container in pod # Gitea container in pod
- name: create gitea-debyl container - name: create gitea-debyl container
@@ -36,7 +36,7 @@
become_user: "{{ git_user }}" become_user: "{{ git_user }}"
containers.podman.podman_container: containers.podman.podman_container:
name: gitea-debyl name: gitea-debyl
image: "{{ gitea_image }}" image: "{{ gitea_debyl_image }}"
pod: gitea-debyl-pod pod: gitea-debyl-pod
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
@@ -66,7 +66,7 @@
volumes: volumes:
- "{{ git_home }}/volumes/gitea/data:/data" - "{{ git_home }}/volumes/gitea/data:/data"
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
tags: gitea tags: gitea, gitea-debyl
# Generate systemd service for the pod # Generate systemd service for the pod
- name: create systemd job for gitea-debyl-pod - name: create systemd job for gitea-debyl-pod
@@ -80,7 +80,7 @@
args: args:
chdir: "{{ git_home }}" chdir: "{{ git_home }}"
changed_when: false changed_when: false
tags: gitea tags: gitea, gitea-debyl
- name: enable gitea-debyl-pod service - name: enable gitea-debyl-pod service
become: true become: true
@@ -91,4 +91,4 @@
enabled: true enabled: true
state: started state: started
scope: user scope: user
tags: gitea tags: gitea, gitea-debyl
+91
View File
@@ -0,0 +1,91 @@
# gitea-actions
Runs the Gitea Actions runners on `home.debyl.io`. One `act_runner` process per
Gitea instance (`git.debyl.io`, `git.skudak.com`), both as the `gitea-runner`
user, both backed by the same rootless podman image store.
## CI job images
Jobs do not run on the host. Each one gets an ephemeral container from one of
three images:
| `runs-on` / `container:` | Image | Used by |
| --- | --- | --- |
| `fedora`, `ubuntu-latest`, `ubuntu-22.04` | `git.debyl.io/gitbot/gitea-ci:latest` | Go / node / web jobs, `docker build` |
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-espidf:<esp_idf_version>` | esp-mg-tpms, skudak/esp32-stm32-vcu |
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-platformio:<pio_espressif32_version>` | skudak/esp32-web-interface |
**This role does not build them.** `.gitea/workflows/ci-images.yml` builds
`files/Containerfile.*` and pushes to the Gitea registry; the role logs
`gitea-runner` in and pulls. Version pins live in `defaults/main.yml` and are
read by both the role and the workflow, so a bump moves the image tag in one
place.
### Why the registry
The images used to exist only as `localhost/gitea-ci*` in the runner's store.
The nightly prune (`roles/podman`, `podman_prune_ci_until: 48h`) deletes any
CI-user image older than that which no container holds, so after an idle
weekend every job failed in under a second on `docker pull
localhost/gitea-ci:latest`, and the only fix was re-running this role and
waiting out a full rebuild.
Two things now keep that from happening:
- **A registry copy.** `force_pull` stays `false`, which in act_runner means
*pull only when missing* — so a present image is never re-fetched, and a
pruned one is restored by the next job without anyone noticing.
- **A prune exemption.** Each Containerfile declares
`LABEL io.debyl.ci-base="true"`, and the prune skips that label
(`podman_prune_ci_keep_label`). Its `until` counts from build time, not pull
time, so without this a re-pulled image would be deleted again the same night
— a 7.8 GB ESP-IDF download every single day.
The label is declared in the Containerfile rather than passed as `--label` so
neither builder can omit it; the workflow re-checks it with `docker inspect`
before pushing.
### Authentication
Both the role and act_runner read `/home/gitea-runner/.docker/config.json`.
act_runner uses it for the job-image pull it performs when a label's image is
missing; podman falls back to the same file. The role writes it from
`gitea_registry_username` / `gitea_registry_token` (vault), so one login covers
both. The `skudak` runner pulls from `git.debyl.io` too — same host, same user,
same file.
The workflow pushes with a `REGISTRY_TOKEN` secret on `bastian/deploy_home`,
belonging to the same `gitbot` user: Gitea authorises a package push by the
token's owner, not by the path, so pushing to `gitbot/` means logging in as
`gitbot`.
### Rebuilding
Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push
to `master`, and the workflow rebuilds only the affected images. It also
rebuilds everything weekly so base-image updates land without a commit, and
takes a `workflow_dispatch` with an image selector.
Pull requests build but do not push, under a throwaway `:pr-<n>` tag that is
deleted afterwards. The build runs in the live runner's image store, so a PR
tagged with the real name would hand every later job on this host an unmerged
image.
### Bootstrap / CI is down
The workflow that builds `gitea-ci` runs *in* `gitea-ci`, so a registry that has
never held it cannot bootstrap itself. Build on the host instead:
```sh
make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
```
That builds all three from the same Containerfiles and pushes them. One run is
enough even on a cold registry: `tasks/main.yml` imports `images.yml` before
`runner.yml`, so the images are published before the runner labels are flipped
to point at them.
The alternative first-time path is to merge the workflow and dispatch it while
the deployed labels still say `localhost/` — the job then builds inside the old
local image and seeds the registry — then run a plain
`make deploy TAGS=gitea-actions` to switch the labels over.
+56 -6
View File
@@ -22,20 +22,70 @@ act_runner_bin: /usr/local/bin/act_runner
act_runner_config_dir: /etc/act_runner act_runner_config_dir: /etc/act_runner
act_runner_work_dir: /var/lib/act_runner act_runner_work_dir: /var/lib/act_runner
# Job container images (built locally into the gitea-runner rootless image # Job container images, served from the Gitea container registry.
# store by tasks/images.yml; never pulled — force_pull is false). #
gitea_ci_image: localhost/gitea-ci:latest # They used to live only under localhost/, built by this role. The nightly
# podman prune (roles/podman: podman_prune_ci_until) deletes any CI-user image
# older than 48h that no container is using, so every idle weekend CI failed in
# 0-1s on `docker pull localhost/gitea-ci:latest` until someone re-ran the role
# and waited out a full rebuild.
#
# Now .gitea/workflows/ci-images.yml builds them from files/Containerfile.* and
# pushes them here, and this role only pulls. A pruned image is re-pulled by the
# next job on its own (force_pull stays false, which means "pull only when
# missing", so a present image is never re-fetched).
#
# Workflows that pin `container: image:` must use these registry paths too
# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface).
gitea_ci_registry: git.debyl.io
# Namespace = the owner of gitea_registry_username / gitea_registry_token (vault).
gitea_ci_registry_namespace: gitbot
gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest"
# ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from. # ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from.
esp_idf_version: v5.4.1 esp_idf_version: v5.4.1
gitea_ci_espidf_image: "localhost/gitea-ci-espidf:{{ esp_idf_version }}" gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}"
# PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface). # PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface).
# Tag tracks the pre-baked espressif32 platform version; both pins match the # Tag tracks the pre-baked espressif32 platform version; both pins match the
# hardware-validated local build. # hardware-validated local build.
platformio_core_version: "6.1.19" platformio_core_version: "6.1.19"
pio_espressif32_version: "7.0.1" pio_espressif32_version: "7.0.1"
gitea_ci_platformio_image: "localhost/gitea-ci-platformio:{{ pio_espressif32_version }}" gitea_ci_platformio_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-platformio:{{ pio_espressif32_version }}"
# Default labels for every runner — map runs-on values to the local CI image. # Registry credentials for the gitea-runner user. The Docker-format path is read
# by both act_runner (to authenticate job image pulls) and podman (as its
# fallback auth file), so one login covers the runner and this role.
gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json"
# The images this role keeps present on the runner. `build_args` is a literal
# podman-build argument string (podman_image has no structured build-arg
# option) and is only used by the gitea_ci_build_local fallback below -- the
# workflow passes the same --build-arg values, read out of the version vars
# above, so there is one source of truth for the pins.
gitea_ci_images:
- image: "{{ gitea_ci_image }}"
containerfile: Containerfile.ci
build_args: ""
- image: "{{ gitea_ci_espidf_image }}"
containerfile: Containerfile.espidf
build_args: "--build-arg ESP_IDF_VERSION={{ esp_idf_version }}"
- image: "{{ gitea_ci_platformio_image }}"
containerfile: Containerfile.platformio
build_args: >-
--build-arg PLATFORMIO_CORE_VERSION={{ platformio_core_version }}
--build-arg PIO_ESPRESSIF32_VERSION={{ pio_espressif32_version }}
# Escape hatch: build the images on the host and push them, instead of pulling
# what CI published. Needed to seed a brand-new registry namespace, and when CI
# itself is down -- the workflow that builds gitea-ci runs *in* gitea-ci, so a
# registry that has never held it cannot bootstrap itself.
#
# make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
#
# Off by default: a plain deploy should never sit through a 15-minute ESP-IDF
# rebuild, and two publishers racing on the same tag is worth avoiding.
gitea_ci_build_local: false
# Default labels for every runner — map runs-on values to the registry CI image.
# Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow. # Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow.
gitea_runner_labels: gitea_runner_labels:
- "fedora:docker://{{ gitea_ci_image }}" - "fedora:docker://{{ gitea_ci_image }}"
@@ -1,8 +1,18 @@
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions). # Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
# Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless # Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless
# podman socket). Go toolchains are provided per-job by actions/setup-go. # podman socket). Go toolchains are provided per-job by actions/setup-go.
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result (see gitea_ci_build_local for the local-build fallback).
# A plain Containerfile, not a template, so CI and ansible build the same bytes.
FROM node:20-bookworm-slim FROM node:20-bookworm-slim
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ARG DOCKER_CLI_VERSION=27.3.1 ARG DOCKER_CLI_VERSION=27.3.1
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -14,7 +14,19 @@
# the release aborts *after* the firmware and version.json are already live — # the release aborts *after* the firmware and version.json are already live —
# clients get the new build while the tag, Gitea release and protocol manifest # clients get the new build while the tag, Gitea release and protocol manifest
# are never written. Keep it installed. # are never written. Keep it installed.
FROM espressif/idf:{{ esp_idf_version }} #
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. ESP_IDF_VERSION is a build arg rather than an ansible
# template var so CI and ansible build the same bytes -- its value is read from
# esp_idf_version in roles/gitea-actions/defaults/main.yml by both.
ARG ESP_IDF_VERSION
FROM espressif/idf:${ESP_IDF_VERSION}
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls 7.8 GB.
LABEL io.debyl.ci-base="true"
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates unzip jq python3-yaml python3-jinja2 \ curl ca-certificates unzip jq python3-yaml python3-jinja2 \
@@ -8,8 +8,23 @@
# was validated on hardware — bump pio_espressif32_version / # was validated on hardware — bump pio_espressif32_version /
# platformio_core_version in defaults/main.yml to upgrade (the image tag # platformio_core_version in defaults/main.yml to upgrade (the image tag
# tracks the platform version). # tracks the platform version).
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. The pins are build args rather than ansible template
# vars so CI and ansible build the same bytes -- their values are read from
# platformio_core_version / pio_espressif32_version in
# roles/gitea-actions/defaults/main.yml by both.
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
ARG PLATFORMIO_CORE_VERSION
ARG PIO_ESPRESSIF32_VERSION
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ENV PLATFORMIO_CORE_DIR=/opt/platformio ENV PLATFORMIO_CORE_DIR=/opt/platformio
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -18,12 +33,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio=={{ platformio_core_version }} RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION}
# Seed project mirroring the real projects' platformio.ini so `pio pkg install` # Seed project mirroring the real projects' platformio.ini so `pio pkg install`
# pulls the platform + toolchain + framework packages into the core dir. # pulls the platform + toolchain + framework packages into the core dir.
# %s + a quoted argument, not ${...} inside the single-quoted format string:
# RUN is `sh -c`, and sh does not expand inside single quotes, so an inlined
# ${PIO_ESPRESSIF32_VERSION} would be written to platformio.ini literally.
RUN mkdir -p /tmp/seed/src \ RUN mkdir -p /tmp/seed/src \
&& printf '[env:seed]\nplatform = espressif32@{{ pio_espressif32_version }}\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' > /tmp/seed/platformio.ini \ && printf '[env:seed]\nplatform = espressif32@%s\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' "${PIO_ESPRESSIF32_VERSION}" > /tmp/seed/platformio.ini \
&& pio pkg install -d /tmp/seed \ && pio pkg install -d /tmp/seed \
&& pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \ && pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \
&& rm -rf /tmp/seed \ && rm -rf /tmp/seed \
+70 -55
View File
@@ -1,4 +1,51 @@
--- ---
# CI job images. .gitea/workflows/ci-images.yml builds files/Containerfile.*
# and pushes them to the Gitea registry; this role only logs the runner in and
# makes sure the images are present, so a plain deploy never waits on a build.
#
# Set gitea_ci_build_local=true to build and push from here instead -- see the
# comment on that variable in defaults/main.yml.
- name: create gitea-runner registry auth directory
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.file:
path: "{{ gitea_ci_registry_authfile | dirname }}"
state: directory
mode: "0700"
tags: gitea-actions
# Docker-format path on purpose: act_runner reads ~/.docker/config.json to
# authenticate the job-image pull it does when a label's image is missing, and
# podman falls back to the same file. One login covers both.
- name: log gitea-runner in to the Gitea container registry
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_login:
registry: "{{ gitea_ci_registry }}"
username: "{{ gitea_registry_username }}"
password: "{{ gitea_registry_token }}"
authfile: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
no_log: true
tags: gitea-actions
- name: pull CI images from the registry
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ item.image }}"
auth_file: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.image }}"
when: not (gitea_ci_build_local | bool)
tags: gitea-actions
# --- local build fallback (gitea_ci_build_local=true) ------------------------
# Only reached when seeding a new namespace or when CI cannot build for us.
- name: create CI image build directory - name: create CI image build directory
become: true become: true
become_user: "{{ gitea_runner_user }}" become_user: "{{ gitea_runner_user }}"
@@ -6,73 +53,41 @@
path: "{{ gitea_runner_home }}/ci-images" path: "{{ gitea_runner_home }}/ci-images"
state: directory state: directory
mode: "0755" mode: "0755"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
- name: stage default CI Containerfile # copy, not template: these are plain Containerfiles that CI builds verbatim.
# Versions come in as --build-arg from the same defaults/main.yml the workflow
# reads, so neither builder can drift from the other.
- name: stage CI Containerfiles
become: true become: true
become_user: "{{ gitea_runner_user }}" become_user: "{{ gitea_runner_user }}"
ansible.builtin.template: ansible.builtin.copy:
src: Containerfile.ci src: "{{ item.containerfile }}"
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.ci" dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
mode: "0644" mode: "0644"
register: ci_containerfile loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.containerfile }}"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
- name: stage ESP-IDF CI Containerfile - name: build and push CI images
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.espidf.j2
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
mode: "0644"
register: espidf_containerfile
tags: gitea-actions
- name: build default CI image ({{ gitea_ci_image }})
become: true become: true
become_user: "{{ gitea_runner_user }}" become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image: containers.podman.podman_image:
name: "{{ gitea_ci_image }}" name: "{{ item.image }}"
path: "{{ gitea_runner_home }}/ci-images" path: "{{ gitea_runner_home }}/ci-images"
build: build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.ci" file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
force: "{{ ci_containerfile is changed }}" extra_args: "{{ item.build_args }}"
environment: force: true
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" push: true
tags: gitea-actions auth_file: "{{ gitea_ci_registry_authfile }}"
- name: stage PlatformIO CI Containerfile
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.platformio.j2
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio"
mode: "0644"
register: platformio_containerfile
tags: gitea-actions
- name: build ESP-IDF CI image ({{ gitea_ci_espidf_image }})
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_espidf_image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
force: "{{ espidf_containerfile is changed }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
tags: gitea-actions
- name: build PlatformIO CI image ({{ gitea_ci_platformio_image }})
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_platformio_image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio"
force: "{{ platformio_containerfile is changed }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.image }}"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
+8
View File
@@ -311,6 +311,14 @@ podman_prune_ci_users:
- gitea-runner - gitea-runner
- actions-runner - actions-runner
podman_prune_ci_until: 48h podman_prune_ci_until: 48h
# The CI base images declare LABEL io.debyl.ci-base="true" in
# roles/gitea-actions/files/Containerfile.* (and .gitea/workflows/ci-images.yml
# verifies it before publishing -- keep all three in sync). Images carrying it
# are skipped below: `until` counts from build time and not pull time, so
# without the exemption a re-pulled image would be deleted again the next
# night, a 7.8 GB ESP-IDF re-download after every idle day. Superseded tags
# (e.g. after an esp_idf_version bump) survive too; remove those by hand.
podman_prune_ci_keep_label: io.debyl.ci-base
# Daily rather than weekly: CI turns over many images a day, and a week of that # Daily rather than weekly: CI turns over many images a day, and a week of that
# is what let the store reach 113 GB between runs. # is what let the store reach 113 GB between runs.
+170 -408
View File
@@ -192,46 +192,53 @@
mode: single mode: single
- id: '1762116115638' - id: '1762116115638'
alias: Light - TV On alias: Light - TV On
description: '' description: TV mode on; once it's dark, dim the living room and turn off the
lights that glare on the TV
triggers: triggers:
- type: turned_on - trigger: state
device_id: 18a9bb7a2a32be4371da447767ef50a9 entity_id: remote.samsung_tv
entity_id: c05688f2610e27e2d86380e2945ceae5 to: 'on'
domain: remote not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: input_boolean.turn_on - action: input_boolean.turn_on
target: target:
entity_id: input_boolean.tv_mode entity_id: input_boolean.tv_mode
- if:
- condition: or
conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: sun
before: sunrise
then:
- action: light.turn_on - action: light.turn_on
metadata: {}
data: data:
brightness_pct: 5 brightness_pct: 5
target: target:
area_id: living_room area_id: living_room
- type: turn_off - action: light.turn_off
device_id: 03a12d2360d9954aed19c2449070725a target:
entity_id: 7c1e7db73799cc3f90948b5118596985 entity_id:
domain: light - light.kitchen_wall_light
- type: turn_off - light.dining_hall
device_id: 800eddbeeda071225f181a14cb9527e0 - light.bathroom_hallway
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
mode: single mode: single
- id: new_tv_off - id: new_tv_off
alias: Light - TV Off - Restore alias: Light - TV Off - Restore
description: Restores appropriate lighting level when TV turns off based on time description: Evening (sunset -1h to 23:30) brings the lights back to the
scheduled level; late night (23:30 to sunrise) only turns off the TV glow;
daytime leaves the lights alone
triggers: triggers:
- type: turned_off - trigger: state
device_id: 18a9bb7a2a32be4371da447767ef50a9 entity_id: remote.samsung_tv
entity_id: c05688f2610e27e2d86380e2945ceae5 to: 'off'
domain: remote not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: input_boolean.turn_off - action: input_boolean.turn_off
@@ -239,409 +246,163 @@
entity_id: input_boolean.tv_mode entity_id: input_boolean.tv_mode
- choose: - choose:
- conditions: - conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: time - condition: time
after: '22:30:00' before: '23:30:00'
before: '23:45:00'
sequence: sequence:
# Late dim levels - action: script.evening_lights_apply
- type: turn_on data:
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 apply: force
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
- conditions: - conditions:
- condition: or
conditions:
- condition: time - condition: time
after: '21:30:00' after: '23:30:00'
before: '22:30:00' - condition: sun
before: sunrise
sequence: sequence:
# Mid dim levels - action: light.turn_off
- type: turn_on target:
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 area_id: living_room
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
- conditions:
- condition: time
after: '21:00:00'
before: '21:30:00'
sequence:
# Early dim levels
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
default:
# Full brightness (before 21:00 after sunset)
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single mode: single
- id: 'sunset_lights_on' - id: driveway_lights_on
alias: Lights - Sunset On alias: Driveway String Lights On
description: Turn on lights 1 hour before sunset description: On 1 hour before sunset whether or not the TV is on. Also catches
up if Home Assistant restarts before the 23:00 off
triggers: triggers:
- trigger: sun - trigger: sun
event: sunset event: sunset
offset: "-01:00:00" offset: "-01:00:00"
id: sunset
- trigger: homeassistant
event: start
conditions: conditions:
- condition: state - condition: state
entity_id: input_boolean.tv_mode entity_id: switch.driveway_string_lights
state: 'off' state: 'off'
actions: - condition: or
- type: turn_on
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single
- id: 'evening_dim_2100'
alias: Lights - Evening Dim (21:00)
description: Dim lights at 21:00 - only affects lights that are ON
triggers:
- trigger: time
at: "21:00:00"
conditions: conditions:
- condition: state - condition: trigger
entity_id: input_boolean.tv_mode id: sunset
state: 'off' - condition: and
actions:
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
mode: single
- id: 'mid_dim_2130'
alias: Lights - Mid Dim (21:30)
description: Dim lights at 21:30 - only affects lights that are ON
triggers:
- trigger: time
at: "21:30:00"
conditions: conditions:
- condition: state - condition: sun
entity_id: input_boolean.tv_mode after: sunset
state: 'off' after_offset: "-01:00:00"
- condition: time
before: '23:00:00'
actions: actions:
- if: - action: switch.turn_on
- condition: device target:
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 entity_id: switch.driveway_string_lights
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 800eddbeeda071225f181a14cb9527e0
domain: light
entity_id: 521a92ddd8be76c7eddfc544f81f6020
type: is_on
then:
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
mode: single mode: single
- id: 'late_dim_2230' - id: 'sunset_lights_on'
alias: Lights - Late Dim (22:30) alias: Lights - Sunset On
description: Dim lights at 22:30 - only affects lights that are ON description: Turn on lights 1 hour before sunset. If the TV is on, the living
room gets the TV glow and the lights that glare on the TV stay off
triggers: triggers:
- trigger: time - trigger: sun
at: "22:30:00" event: sunset
conditions: offset: "-01:00:00"
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
actions:
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- if:
- condition: device
device_id: 800eddbeeda071225f181a14cb9527e0
domain: light
entity_id: 521a92ddd8be76c7eddfc544f81f6020
type: is_on
then:
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
mode: single
- id: 'lights_out_2345'
alias: Lights - Out (23:45)
description: Turn off all lights at 23:45
triggers:
- trigger: time
at: "23:45:00"
conditions: [] conditions: []
actions: actions:
- type: turn_off - action: script.evening_lights_apply
device_id: 3f7f65571d9bb0833433996f1f6725bd data:
entity_id: 7407afe14783543252c666d5ff7c5d5c apply: force
domain: light - if:
- type: turn_off - condition: state
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 entity_id: input_boolean.tv_mode
entity_id: b79934d97f3bb9d8a3da47c76d03ded4 state: 'on'
domain: light then:
- type: turn_off - action: light.turn_on
device_id: 03a12d2360d9954aed19c2449070725a data:
entity_id: 7c1e7db73799cc3f90948b5118596985 brightness_pct: 5
domain: light target:
- type: turn_off area_id: living_room
device_id: 800eddbeeda071225f181a14cb9527e0 mode: single
entity_id: 521a92ddd8be76c7eddfc544f81f6020 - id: evening_dim_ramp
domain: light alias: Lights - Evening Dim Ramp
- type: turn_off description: Every 5 minutes from 20:30 to 23:30, ease the lights that are on
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd toward the schedule in script.evening_lights_apply
entity_id: a30b2da3cd80a5b4c927e1608b91eb65 triggers:
domain: light - trigger: time_pattern
- type: turn_off minutes: /5
device_id: 21eb2bd28aba2ee361a22af92e8b2d16 conditions:
entity_id: 81c486d682afcc94e98e377475cc92fc - condition: time
domain: light after: '20:30:00'
before: '23:30:00'
actions:
- action: script.evening_lights_apply
data:
apply: ramp
mode: single
- id: 'lights_out_2345'
alias: Lights - Out (23:30)
description: Turn off all lights at 23:30. While the TV is on the living room
is left as it is
triggers:
- trigger: time
at: "23:30:00"
conditions: []
actions:
- action: light.turn_off
target:
entity_id:
- light.kitchen_lights
- light.kitchen_wall_light
- light.dining_hall
- light.dining_room
- light.bathroom_hallway
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
then:
- action: light.turn_off
target:
entity_id: light.living_room
mode: single
- id: lights_sweep_0100
alias: Lights - Sweep (01:00)
description: Catch anything turned back on after lights-out. While the TV is
on the living room is left as it is
triggers:
- trigger: time
at: "01:00:00"
conditions: []
actions:
- action: light.turn_off
target:
entity_id:
- light.kitchen_lights
- light.kitchen_wall_light
- light.dining_hall
- light.dining_room
- light.bathroom_hallway
- action: switch.turn_off
target:
entity_id: switch.driveway_string_lights
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
then:
- action: light.turn_off
target:
entity_id: light.living_room
mode: single mode: single
- id: '1768862300896' - id: '1768862300896'
alias: Bedroom On alias: Bedroom On
description: '' description: ''
triggers: triggers:
- type: turned_on - trigger: state
device_id: afb9734fe9b187ab6881a64d24e1c2f5 entity_id: switch.bedroom_light
entity_id: 27efa149b9ebb388e7c21ba89e671b42 to: 'on'
domain: switch not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: light.turn_on - action: light.turn_on
@@ -655,11 +416,12 @@
alias: Bedroom Off alias: Bedroom Off
description: '' description: ''
triggers: triggers:
- type: turned_off - trigger: state
device_id: afb9734fe9b187ab6881a64d24e1c2f5 entity_id: switch.bedroom_light
entity_id: 27efa149b9ebb388e7c21ba89e671b42 to: 'off'
domain: switch not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: light.turn_off - action: light.turn_off
@@ -23,6 +23,7 @@ homeassistant:
media: /share media: /share
automation: !include automations.yaml automation: !include automations.yaml
script: !include scripts.yaml
input_boolean: input_boolean:
tv_mode: tv_mode:
@@ -0,0 +1,76 @@
evening_lights_apply:
alias: Evening Lights - Apply Schedule
description: >-
Sets each evening light to its scheduled brightness for the current time,
blending linearly between the points in `schedule`. apply=force turns the
lights on (sunset, TV off); apply=ramp only eases lights that are already
on, and leaves alone any light someone has changed by hand. While TV mode
is on the living room and the lights that glare on the TV are left alone.
mode: queued
fields:
apply:
description: "force: turn lights on at the target. ramp: only adjust lights that are already on."
example: ramp
selector:
select:
options:
- force
- ramp
variables:
# [minute of day, brightness %] - 1230 = 20:30, 1260 = 21:00,
# 1290 = 21:30, 1350 = 22:30. Before the first point a light sits at the
# first value; after the last it holds the last value until lights-out.
schedule:
light.kitchen_lights: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
light.kitchen_wall_light: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
light.bathroom_hallway: [[1230, 75], [1260, 50], [1290, 25], [1350, 10]]
light.living_room: [[1230, 100], [1260, 50], [1290, 25], [1350, 10]]
light.dining_hall: [[1290, 25], [1350, 15]]
tv_mode_lights:
- light.living_room
- light.kitchen_wall_light
- light.dining_hall
- light.bathroom_hallway
apply_mode: "{{ apply | default('ramp') }}"
sequence:
- repeat:
for_each: "{{ schedule.keys() | list }}"
sequence:
- variables:
light: "{{ repeat.item }}"
# [target now, target 5 minutes ago - what the last ramp tick set]
levels: >-
{%- macro at(pts, t) -%}
{%- if t <= pts[0][0] -%}{{ pts[0][1] }}
{%- elif t >= pts[-1][0] -%}{{ pts[-1][1] }}
{%- else -%}
{%- for i in range(pts | length - 1) if pts[i][0] <= t < pts[i + 1][0] -%}
{{ (pts[i][1] + (pts[i + 1][1] - pts[i][1]) * (t - pts[i][0]) / (pts[i + 1][0] - pts[i][0])) | round(0) | int }}
{%- endfor -%}
{%- endif -%}
{%- endmacro -%}
{%- set t = now().hour * 60 + now().minute -%}
{{ [at(schedule[repeat.item], t) | int, at(schedule[repeat.item], t - 5) | int] }}
current: "{{ ((state_attr(repeat.item, 'brightness') or 0) / 2.55) | round(0) | int }}"
skip: "{{ is_state('input_boolean.tv_mode', 'on') and repeat.item in tv_mode_lights }}"
- choose:
- conditions: "{{ not skip and apply_mode == 'force' }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ light }}"
data:
brightness_pct: "{{ levels[0] }}"
transition: 2
# A light more than 10 points off the last tick was set by hand; the
# biggest scheduled change in 5 minutes is ~8
- conditions: >-
{{ not skip and apply_mode == 'ramp' and is_state(light, 'on')
and (current - levels[1]) | abs <= 10 and current != levels[0] }}
sequence:
- action: light.turn_on
target:
entity_id: "{{ light }}"
data:
brightness_pct: "{{ levels[0] }}"
transition: 60
@@ -25,6 +25,7 @@
loop: loop:
- configuration.yaml - configuration.yaml
- automations.yaml - automations.yaml
- scripts.yaml
- name: flush handlers - name: flush handlers
ansible.builtin.meta: flush_handlers ansible.builtin.meta: flush_handlers
+3 -3
View File
@@ -39,7 +39,7 @@
- import_tasks: containers/home/hass.yml - import_tasks: containers/home/hass.yml
vars: vars:
image: ghcr.io/home-assistant/home-assistant:2026.8.3 image: ghcr.io/home-assistant/home-assistant:2026.9.3
tags: hass tags: hass
- import_tasks: containers/home/partsy.yml - import_tasks: containers/home/partsy.yml
@@ -123,14 +123,14 @@
- import_tasks: containers/home/gregtime.yml - import_tasks: containers/home/gregtime.yml
vars: vars:
image: localhost/greg-time-bot:3.18.1 image: localhost/greg-time-bot:3.19.0
tags: gregtime tags: gregtime
# Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to # Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to
# the VERSION it loaded. The Caddy vhost ships with the caddy-config tag. # the VERSION it loaded. The Caddy vhost ships with the caddy-config tag.
- import_tasks: containers/home/rsvp.yml - import_tasks: containers/home/rsvp.yml
vars: vars:
image: localhost/rsvpd:1.0.5 image: localhost/rsvpd:1.0.7
tags: rsvp tags: rsvp
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken # Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
@@ -43,9 +43,10 @@ run() {
exec podman "$@"' _ "$@" exec podman "$@"' _ "$@"
} }
# prune_user <user> <until> <prune_containers: yes|no> # prune_user <user> <until> <prune_containers: yes|no> [image prune filter...]
prune_user() { prune_user() {
local u=$1 keep=$2 do_containers=$3 local u=$1 keep=$2 do_containers=$3
shift 3
local before after img vol con local before after img vol con
if ! id "$u" >/dev/null 2>&1; then if ! id "$u" >/dev/null 2>&1; then
@@ -66,7 +67,7 @@ prune_user() {
con=$(run "$u" container prune -f --filter "until=$keep" 2>&1 | tail -1) con=$(run "$u" container prune -f --filter "until=$keep" 2>&1 | tail -1)
fi fi
img=$(run "$u" image prune -af --filter "until=$keep" 2>&1 | tail -1) img=$(run "$u" image prune -af --filter "until=$keep" "$@" 2>&1 | tail -1)
vol=$(run "$u" volume prune -f 2>&1 | tail -1) vol=$(run "$u" volume prune -f 2>&1 | tail -1)
after=$(run "$u" system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1) after=$(run "$u" system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1)
@@ -80,7 +81,10 @@ for u in {{ podman_prune_users | join(' ') }}; do
done done
for u in {{ podman_prune_ci_users | join(' ') }}; do for u in {{ podman_prune_ci_users | join(' ') }}; do
prune_user "$u" "{{ podman_prune_ci_until }}" yes # CI base images (gitea-ci, -espidf, -platformio) carry the keep label: they
# are rebuilt or re-pulled from the registry only when missing, so pruning
# them just forces a multi-GB re-download on the next job.
prune_user "$u" "{{ podman_prune_ci_until }}" yes --filter "label!={{ podman_prune_ci_keep_label }}"
done done
log "status=ok" log "status=ok"
Binary file not shown.