Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions .claude/hooks/guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
# 9. gh workflow run · gh api ... dispatches
# 10. prisma migrate deploy|reset|resolve · prisma db push|execute
# 11. odoo-bin / odoo with -u/--update/-i/--init
# 12. git push of a RELEASE TAG: any refspec naming (or globbing) a v<digit>… tag — v1.2.3, refs/tags/v1.2.3,
# "v*", refs/tags/*, `origin tag v1.2.3` — and --tags / --follow-tags (they carry every local v* tag).
# In Veton-app a pushed v* tag ships to Play production + TestFlight (Codemagic), so a release tag is
# a human action. `git tag v1.2.3` itself stays allowed (local, harmless); non-v tags (docs-2026) push.
#
# Known gaps (documented, not hidden): a command fed to a shell via a file or heredoc
# (`bash < script`), aliases/functions defined earlier in the same session, and `git config` tricks
Expand Down Expand Up @@ -178,31 +182,46 @@ check_head_policy() { # `git push` with no refspec / HEAD: pushes the CURRENT br
return 0
}

TAG_BLOCK_MSG="release tags are pushed by a human (Codemagic/Play/TestFlight trigger) — see 95-agent-guide/guardrails.md"

is_release_tag_ref() { # $1 = one side of a refspec (quotes already stripped). True when it names, or a
local r="$1" t # glob could match, a v<digit>… tag: v1.2.3 · refs/tags/v1.2.3 · v* · refs/tags/* · refs/*
case "$r" in
refs/tags/*) t="${r#refs/tags/}"; [[ "$t" =~ ^v[0-9] ]] || [[ "$t" =~ ^v?[*?\[] ]] ;;
refs/\**|refs/\?*|refs/\[*) return 0 ;; # refs/* covers refs/tags/*
refs/*) return 1 ;; # refs/heads/…, refs/remotes/… — not a tag
*) [[ "$r" =~ ^v[0-9] ]] || [[ "$r" =~ ^v[*?\[] ]] ;;
esac
}

check_git_push() {
local -a A=("$@")
local i=0 n=${#A[@]} a remote="" src dest tags_only=0
local i=0 n=${#A[@]} a remote="" src dest next_is_tag=0
local -a refspecs=()
while [ "$i" -lt "$n" ]; do
a="${A[$i]}"; i=$((i + 1))
case "$a" in
--force|--force-with-lease|--force-with-lease=*|--force-if-includes|--mirror|--all|--branches|--delete|--prune|-d)
block "git push $a (force / delete / mirror is never done from an agent session)" ;;
-o|--push-option|--repo|--receive-pack|--exec) i=$((i + 1)); continue ;;
--tags) tags_only=1; continue ;; # `git push --tags` pushes refs/tags/* only, never the current branch —
# it is the documented release trigger (Codemagic / TestFlight), so it must
# work from any checkout, `main` included. `--follow-tags` still pushes the
# branch and keeps the current-branch policy; -f/--force still block.
--tags|--follow-tags) block "git push $a would carry every local v* tag — $TAG_BLOCK_MSG" ;;
--*) continue ;;
-*) [[ "$a" =~ ^-[A-Za-z]*[fd] ]] && block "git push $a contains -f (force) or -d (delete)"; continue ;;
*) if [ -z "$remote" ]; then remote="$a"; else refspecs+=("$a"); fi ;;
*) if [ -z "$remote" ]; then remote="$a"
elif [ "$a" = tag ] && [ "$next_is_tag" = 0 ]; then next_is_tag=1 # `git push origin tag v1.2.3`
elif [ "$next_is_tag" = 1 ]; then next_is_tag=0; refspecs+=("refs/tags/$a")
else refspecs+=("$a"); fi ;;
esac
done
if [ "${#refspecs[@]}" -eq 0 ]; then [ "$tags_only" = 1 ] || check_head_policy; return 0; fi
if [ "${#refspecs[@]}" -eq 0 ]; then check_head_policy; return 0; fi
for a in "${refspecs[@]}"; do
[[ "$a" == +* ]] && block "git push with a '+' refspec ($a) is a force push"
if [[ "$a" == *:* ]]; then src="${a%%:*}"; dest="${a#*:}"; else src="$a"; dest="$a"; fi
[ -z "$src" ] && block "git push '$a' (empty source) deletes the remote branch '$dest'"
[ -z "$dest" ] && block "git push '$a' has an empty destination"
if is_release_tag_ref "$src" || is_release_tag_ref "$dest"; then
block "git push of release tag '$a' — $TAG_BLOCK_MSG"
fi
dest="${dest#refs/heads/}"
[[ "$dest" =~ ^(main|master)$ ]] && block "git push to '$dest' — main is changed only by a merged PR"
if [ "$src" = HEAD ] && [[ "$a" != *:* ]]; then check_head_policy; fi
Expand Down
88 changes: 66 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
# Runs on every push + PR (branch signal before merge) and on workflow_call (from deploy.yml).
#
# veton-ha: Home Assistant custom integration. Tests = pytest (tests/, pytest.ini,
# requirements_test.txt: pytest-homeassistant-custom-component). Lint: ruff/pyflakes are not in the
# requirements, so the lint step prints "skipped" — add ruff to requirements_test.txt to enable it.
# requirements_test.txt: pytest-homeassistant-custom-component). Lint: ruff is not in the
# requirements, so the lint step prints a ::warning:: SKIPPED — add ruff to requirements_test.txt to enable it.
# Python 3.13 matches what validate.yml used before this file took over the test job; hassfest +
# HACS validation stay in validate.yml (the kit does not cover them).
name: CI
Expand All @@ -19,6 +19,9 @@ on:
permissions:
contents: read

# Group includes the CALLER's workflow name: when deploy.yml calls this file via workflow_call,
# github.workflow is "Deploy", so that run never cancels (or is cancelled by) the push-triggered
# "CI" run of the same ref. Plain ci-${{ github.ref }} would make the two fight.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand All @@ -27,51 +30,92 @@ jobs:
ci:
name: ci
runs-on: ubuntu-latest
env:
GITLEAKS_VERSION: 8.28.0
GITLEAKS_SHA256: a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0 # gitleaks scans the whole history (--log-opts=--all)

# Secret scan FIRST: a leaked credential must fail the job even if the tests are red.
# Pinned binary + verified checksum, run directly — no gitleaks-action (its org-repo license
# requirement would leave CI red until someone registers). Whole history is scanned.
# The verified tarball is cached (key = version + sha256) so the GitHub release download is not
# a per-run network dependency; on a miss it is fetched with 3 retries. The sha256 is checked on
# EVERY run, cache hit or miss — a cache entry is never trusted on its own.
- name: Cache gitleaks (verified tarball)
id: gitleaks-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/gitleaks/gitleaks_${{ env.GITLEAKS_VERSION }}_linux_x64.tar.gz
key: gitleaks-${{ env.GITLEAKS_VERSION }}-${{ env.GITLEAKS_SHA256 }}

- name: Secret scan (gitleaks v8.28.0, pinned + sha256-verified)
env:
GITLEAKS_VERSION: 8.28.0
GITLEAKS_SHA256: a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb
CACHE_HIT: ${{ steps.gitleaks-cache.outputs.cache-hit }}
run: |
set -euo pipefail
curl -fsSL -o /tmp/gitleaks.tgz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
echo "${GITLEAKS_SHA256} /tmp/gitleaks.tgz" | sha256sum -c -
tar -xzf /tmp/gitleaks.tgz -C /tmp gitleaks
TGZ="$HOME/.cache/gitleaks/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
mkdir -p "$(dirname "$TGZ")"
if [ "$CACHE_HIT" = "true" ] && [ -s "$TGZ" ]; then
echo "gitleaks ${GITLEAKS_VERSION}: tarball from cache"
else
URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
rm -f "$TGZ"
for i in 1 2 3; do
curl -fsSL --max-time 120 -o "$TGZ" "$URL" && break
echo "::warning::gitleaks download attempt $i/3 failed"; rm -f "$TGZ"; sleep 5
done
[ -s "$TGZ" ] || { echo "::error::gitleaks download failed 3 times: $URL"; exit 1; }
fi
echo "${GITLEAKS_SHA256} $TGZ" | sha256sum -c -
tar -xzf "$TGZ" -C /tmp gitleaks
/tmp/gitleaks version
/tmp/gitleaks git --config .gitleaks.toml --redact --exit-code 1 --no-banner --log-opts="--all" .

- uses: actions/setup-python@v5
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ vars.PYTHON_VERSION || '3.13' }} # was 3.13 in validate.yml; HA 2024.12+ needs >=3.12
cache: pip
cache-dependency-path: requirements_test.txt # default glob only matches requirements.txt (2026-08-29 fix)

- name: Install requirements*.txt (all of them)
# The install source is recorded in $GITHUB_ENV (CI_REQ_FILES) so the lint step can check
# whether `ruff` is part of what this repo asked CI to install.
- name: Install (requirements-ci.txt → requirements*.txt → pyproject [test])
run: |
set -euo pipefail
python -m pip install --upgrade pip
found=0
for f in requirements*.txt; do
[ -f "$f" ] || continue
found=1; echo "pip install -r $f"; pip install -r "$f"
done
if [ -f pyproject.toml ] && [ "$found" = 0 ]; then pip install -e . || true; fi
[ "$found" = 1 ] || echo "no requirements*.txt found"
files=""
if [ -f requirements-ci.txt ]; then
files="requirements-ci.txt"
else
for f in requirements*.txt; do [ -f "$f" ] && files="$files $f"; done
files="${files# }"
fi
if [ -n "$files" ]; then
for f in $files; do echo "pip install -r $f"; pip install -r "$f"; done
elif [ -f pyproject.toml ]; then
echo "no requirements*.txt — pip install -e '.[test]' from pyproject.toml"
pip install -e '.[test]' || { echo "::warning::'.[test]' extra failed — installing the bare package"; pip install -e .; }
files="pyproject.toml"
else
echo "::warning::no requirements-ci.txt, requirements*.txt or pyproject.toml — nothing installed"
fi
echo "CI_REQ_FILES=$files" >> "$GITHUB_ENV"
echo "install source: ${files:-none}"

- name: Lint (ruff → pyflakes → none)
- name: Lint (ruff check when ruff is in the CI requirements)
run: |
set -euo pipefail
if command -v ruff >/dev/null 2>&1; then ruff check .
elif python -c "import pyflakes" 2>/dev/null; then python -m pyflakes .
else echo "neither ruff nor pyflakes installed via requirements — lint skipped"; fi
# shellcheck disable=SC2086 # CI_REQ_FILES is a space-separated file list on purpose
if [ -n "${CI_REQ_FILES:-}" ] && grep -qiE '(^|[^a-z0-9_-])ruff([^a-z0-9_-]|$)' $CI_REQ_FILES; then
ruff --version
ruff check .
else
echo "::warning::SKIPPED lint — 'ruff' is not listed in the CI requirements (searched: ${CI_REQ_FILES:-none}). Add ruff to requirements-ci.txt to enable it."
fi

- name: Tests (pytest if a tests dir exists, else compileall)
run: |
Expand All @@ -80,6 +124,6 @@ jobs:
python -c "import pytest" 2>/dev/null || pip install pytest
python -m pytest -q
else
echo "no tests dirrunning python -m compileall as a smoke gate"
echo "::warning::SKIPPED pytest — no tests/ or test/ dir, pytest.ini or conftest.py; running python -m compileall as a smoke gate only"
python -m compileall -q .
fi
5 changes: 4 additions & 1 deletion .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ keywords = ["hmac", "secret", "token", "apikey", "api_key", "passwd", "password"
[[rules]]
id = "veton-sshpass"
description = "sshpass -p <password> on the command line"
regex = '''sshpass\s+-p\s*["']?([^\s"'$`][^\s"']*)'''
regex = '''sshpass\s+-p\s*["']?([^\s"'$`<*][^\s"']*)'''
secretGroup = 1
keywords = ["sshpass"]
# Placeholders are not secrets: <PASSWORD>, ***, $VAR, ${VAR}, `cmd`, 1-2 char values.
[rules.allowlist]
regexes = ['''^(<[^>]*>|\*+|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|.{1,2})$''']

[[rules]]
id = "veton-pass-assignment"
Expand Down
Loading