diff --git a/.claude/hooks/guard.sh b/.claude/hooks/guard.sh index 53a6c0a..05a3e3c 100755 --- a/.claude/hooks/guard.sh +++ b/.claude/hooks/guard.sh @@ -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… 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 @@ -178,9 +182,21 @@ 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… 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)) @@ -188,21 +204,24 @@ check_git_push() { --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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b22e30..a3883d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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: | @@ -80,6 +124,6 @@ jobs: python -c "import pytest" 2>/dev/null || pip install pytest python -m pytest -q else - echo "no tests dir — running 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 diff --git a/.gitleaks.toml b/.gitleaks.toml index ea2b19a..43d9ab7 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -29,9 +29,12 @@ keywords = ["hmac", "secret", "token", "apikey", "api_key", "passwd", "password" [[rules]] id = "veton-sshpass" description = "sshpass -p 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: , ***, $VAR, ${VAR}, `cmd`, 1-2 char values. +[rules.allowlist] +regexes = ['''^(<[^>]*>|\*+|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|.{1,2})$'''] [[rules]] id = "veton-pass-assignment"