Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ jobs:
run: bash tests/self_check.sh

addon-lint:
name: Add-on Lint
name: App Lint
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Home Assistant Add-on Lint
- name: Home Assistant App Lint
uses: frenck/action-addon-linter@f995494fd84fae6310d23617e66d0e37de4f14eb # v2.21.0
with:
path: ./gitops_backup
Expand All @@ -69,7 +69,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Build add-on image
- name: Build app image
run: |
docker build \
--build-arg BUILD_FROM=ghcr.io/home-assistant/amd64-base:3.21 \
Expand Down
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# HA GitOps — Home Assistant Add-on
# HA GitOps — Home Assistant App

**PR-gated GitHub backup for your Home Assistant config.** Nothing force-pushes,
every change is a reviewable pull request, and CI validates your config against
Expand All @@ -10,10 +10,10 @@ pinned Home Assistant versions *before* it can merge.
[![Downloads](https://img.shields.io/github/downloads/askb/ha-gitops/total?label=downloads)](https://github.com/askb/ha-gitops/releases)
[![License](https://img.shields.io/github/license/askb/ha-gitops)](LICENSE)

## Why another config-backup add-on?
## Why another config-backup app?

Most config-sync tools upload your files straight onto a branch — some even
force-push. That gives you a *copy*, not *control*. This add-on treats your
force-push. That gives you a *copy*, not *control*. This app treats your
config like production infrastructure:

| | Direct-push sync tools | GitOps Config Backup |
Expand All @@ -30,7 +30,7 @@ config like production infrastructure:
flowchart LR
subgraph HA["🏠 Home Assistant box"]
CFG[("/config")]
ADDON["GitOps Backup add-on<br/>two-way sync, every run:<br/>⬇ pull merged → ⬆ push drift"]
ADDON["GitOps Backup app<br/>two-way sync, every run:<br/>⬇ pull merged → ⬆ push drift"]
CFG -- "① local edits<br/>(UI, HA upgrades)" --> ADDON
ADDON -- "⑤ merged changes<br/>applied to /config" --> CFG
end
Expand Down Expand Up @@ -68,8 +68,8 @@ flowchart LR
```

**The loop, numbered:** ① you edit via the HA UI (or an upgrade changes files) →
② the add-on turns that drift into a PR — or you open a config PR yourself →
③ CI gates it, you merge → ④ the add-on's next run pulls merged `main` →
② the app turns that drift into a PR — or you open a config PR yourself →
③ CI gates it, you merge → ④ the app's next run pulls merged `main` →
⑤ and applies it to `/config`. Both directions go through the same run:
it **pulls first, then pushes drift**, so box and repo converge on `main`.

Expand All @@ -82,16 +82,16 @@ it **pulls first, then pushes drift**, so box and repo converge on `main`.
a `stable` early-warning leg) **+ a smoke boot** — the pinned HA Core
container starts with your config and must answer HTTP with no
`Invalid config` — upgrade breakage surfaces in CI, not on your Pi.
3. **You merge** (from your phone, if you like). The next add-on run rebases the
3. **You merge** (from your phone, if you like). The next app run rebases the
merged state back onto the box.

## Install

1. Add this repository to your Add-on store (badge above), install
1. Add this repository to your App store (badge above), install
**GitOps Config Backup**.
2. Create a GitHub repo (private recommended) and a fine-grained PAT with
*Contents: read/write* and *Pull requests: read/write* on it.
3. Configure the add-on:
3. Configure the app:

```yaml
github_repo: you/your-ha-config
Expand All @@ -116,7 +116,7 @@ the initial import; every run after that only ever opens PRs.

## Status sensor (optional)

The add-on writes `.gitops_backup_status` (`status:detail:extra`) to your config
The app writes `.gitops_backup_status` (`status:detail:extra`) to your config
dir. Expose it in HA:

```yaml
Expand Down Expand Up @@ -146,23 +146,23 @@ ssh root@<HA_IP> 'ha core restart'
| `base_branch` | `main` | Branch PRs target |
| `branch_prefix` | `auto-backup` | Prefix for backup branches |
| `interval_hours` | `24` | Hours between runs (1–168) |
| `run_at_start` | `false` | Also run when the add-on starts |
| `run_at_start` | `false` | Also run when the app starts |
| `dry_run` | `false` | Log what would change; push nothing |
| `commit_name` / `commit_email` | see config | Commit author |
| `signoff` | `true` | Add `Signed-off-by` (DCO) to commits |

## Security notes

- The token lives in the add-on options (Supervisor-encrypted), never in git.
- The token lives in the app options (Supervisor-encrypted), never in git.
- The seeded `.gitignore` excludes `secrets.yaml`, `.storage/`, `.cloud/`,
databases, logs, and the add-on's own status/log files (committing status
databases, logs, and the app's own status/log files (committing status
files creates a PR-per-day feedback loop — learned the hard way).
- CI validates with **stub** secrets; your real `secrets.yaml` never leaves the box.
- Nothing in this add-on ever force-pushes or writes to your base branch after
- Nothing in this app ever force-pushes or writes to your base branch after
the initial bootstrap import.
- **This is change tracking, not disaster recovery** — pair it with HA's
native encrypted backups + the Google Drive Backup add-on for full system
state (`secrets.yaml`, `.storage/`, databases, add-ons). See the add-on
native encrypted backups + the Google Drive Backup app for full system
state (`secrets.yaml`, `.storage/`, databases, apps). See the app
DOCS for the full "what is backed up where" table and where secrets belong.

## License
Expand Down
12 changes: 6 additions & 6 deletions gitops_backup/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,36 @@
## 0.3.0

- Feature: `apply_after_pull` — after a merged PR is pulled into `/config`, the
add-on now applies it to the running Home Assistant instead of leaving files
app now applies it to the running Home Assistant instead of leaving files
on disk unloaded. Default `reload` calls `homeassistant.reload_all` (no
restart); `restart` restarts HA (for `configuration.yaml` integration or
`custom_components/` changes); `off` keeps the previous behaviour. Only fires
when the sync actually advanced `HEAD`, never in `dry_run`. Requires the
add-on's Home Assistant API access (now enabled via `homeassistant_api`).
app's Home Assistant API access (now enabled via `homeassistant_api`).

## 0.2.1

- Security: the never-commit guarantee (secrets, credentials, `.storage/`,
addon runtime files) now holds for **migrated** repos too, not just fresh
app runtime files) now holds for **migrated** repos too, not just fresh
ones. Patterns are enforced via `.git/info/exclude` on every run, and any
secret a prior run already committed is untracked (the removal flows out
through the next backup PR). Previously a repo that arrived with its own
`.gitignore` lacking these entries could leak an OAuth token or key.

## 0.2.0

- Docs: make the two-pillar backup model explicit — config repo (this add-on)
- Docs: make the two-pillar backup model explicit — config repo (this app)
+ encrypted HA full backup (e.g. Google Drive Backup) = 100% restorable;
neither alone is enough. Sharpened the "what is backed up where" table
(names exactly what `.storage/` holds and what lives outside `/config`).
- Security: seed `.gitignore` now also excludes `*.token` / `.google.token`
so OAuth tokens (e.g. Google Calendar) never land in the repo.
- First bootstrap now logs a disaster-recovery warning: this add-on versions
- First bootstrap now logs a disaster-recovery warning: this app versions
declarative config only; run HA full backups for a complete restore.

## 0.1.1

- Fix: keep the addon's own runtime files (`.gitops_backup_status`,
- Fix: keep the app's own runtime files (`.gitops_backup_status`,
`gitops_backup.log`) out of backups when migrating a repo that already has a
`.gitignore` — they were previously swept into backup PRs and risked a
status-file PR feedback loop. Now excluded via `.git/info/exclude` on every
Expand Down
30 changes: 15 additions & 15 deletions gitops_backup/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ PR-gated GitHub backup for your Home Assistant config.
### 1. Create the repository

github.com → **+** → **New repository** → name it (e.g. `my-ha-config`) →
**Private** → Create. Don't add a README — the add-on bootstraps the content.
**Private** → Create. Don't add a README — the app bootstraps the content.

### 2. Create the token

1. github.com → your avatar → **Settings** → **Developer settings** →
**Personal access tokens** → **Fine-grained tokens** → **Generate new token**
2. **Token name**: `ha-gitops` · **Expiration**: 1 year (set a calendar
reminder — the add-on status will show `error` when it expires)
reminder — the app status will show `error` when it expires)
3. **Repository access**: *Only select repositories* → pick the repo from step 1
4. **Permissions → Repository permissions**:
- **Contents**: Read and write
Expand All @@ -31,21 +31,21 @@ github.com → **+** → **New repository** → name it (e.g. `my-ha-config`)
> The token can only touch that single repo. Even if it leaked, your other
> repos and account are untouchable — that's why fine-grained beats classic PATs.

### 3. Configure the add-on
### 3. Configure the app

Settings → Add-ons → GitOps Config Backup → **Configuration**:
Settings → Apps → GitOps Config Backup → **Configuration**:

```yaml
github_repo: yourname/my-ha-config
github_token: github_pat_XXXXXXXX
dry_run: true # first run: log only, push nothing
```

Start the add-on, then check `gitops_backup.log` in your config folder to see
Start the app, then check `gitops_backup.log` in your config folder to see
what *would* be committed. Happy? Set `dry_run: false` and restart. The first
real run pushes the initial import; every run after that only opens PRs.

The token is stored in the add-on options by the Supervisor — it never goes
The token is stored in the app options by the Supervisor — it never goes
into git, and the seeded `.gitignore` keeps `secrets.yaml` and other sensitive
files out of the repo entirely.

Expand All @@ -59,30 +59,30 @@ repo → Settings → Branches → require the *HA Config Validation* and

## What is backed up where (best practice)

This add-on is **change tracking for your config**, not disaster recovery.
This app is **change tracking for your config**, not disaster recovery.
Run both layers:

| Data | This add-on (GitHub) | HA Backup + Google Drive |
| Data | This app (GitHub) | HA Backup + Google Drive |
|------|----------------------|--------------------------|
| Config YAML (automations, dashboards, …) | ✅ versioned, reviewable diffs | ✅ inside the archive |
| `secrets.yaml` | ❌ **never** (gitignored) | ✅ inside the **encrypted** archive |
| `.storage/` — UI-added **integrations**, **logins/tokens**, **device/entity/area registries**, **UI (Lovelace) dashboards**, helpers | ❌ never (holds secrets) | ✅ **most restore-critical item** |
| Credentials: `*.token`, `.google.token`, `*.key`, `*.pem`, `.cloud/` | ❌ never | ✅ |
| Databases, history, logs | ❌ never | ✅ (recorder DB, if selected) |
| Add-on configs/data, TLS certs (`/ssl`) — live **outside** `/config` | ❌ add-on can't see them | ✅ |
| App configs/data, TLS certs (`/ssl`) — live **outside** `/config` | ❌ app can't see them | ✅ |
| Media, camera recordings | ❌ | usually excluded — external drive |

> **The 100% guarantee: config repo + encrypted full backup. Neither alone is
> enough.** Restore from the git repo *only* and you'd get your YAML back but
> have to re-do by hand every UI-added integration, all logins/tokens, device
> and entity names, UI dashboards, `secrets.yaml`, and every add-on — because
> and entity names, UI dashboards, `secrets.yaml`, and every app — because
> those live in `.storage/`, `secrets.yaml`, or outside `/config`. That's why a
> full backup (Pillar 2) is mandatory, not optional.

**Recommended stack:**

```text
GitHub (this add-on) → config changes: who/what/when, PR review, CI gate
GitHub (this app) → config changes: who/what/when, PR review, CI gate
HA auto backup (daily) → full system state, encrypted, on the box
└─ Google Drive Backup → off-site copies of those archives
Password manager → the three keys to the kingdom (below)
Expand All @@ -96,13 +96,13 @@ Password manager → the three keys to the kingdom (below)
- Store in your **password manager** (never in the repo, never in a README):
1. the HA **backup encryption password** — without it your off-site backups
are unrestorable,
2. the **GitHub PAT** for this add-on,
2. the **GitHub PAT** for this app,
3. your HA admin credentials.
- CI validates PRs with **stub** secrets (step 4) — real values never leave
the box.

**Disaster recovery order:** reinstall HAOS → restore the encrypted HA backup
(brings back `secrets.yaml`, `.storage`, add-ons) → your config repo is then
(brings back `secrets.yaml`, `.storage`, apps) → your config repo is then
already live on the box and git history simply resumes.

## Options
Expand All @@ -114,7 +114,7 @@ already live on the box and git history simply resumes.
| `base_branch` | `main` | Branch PRs target |
| `branch_prefix` | `auto-backup` | Prefix for backup branches |
| `interval_hours` | `24` | Hours between runs (1–168) |
| `run_at_start` | `false` | Also run when the add-on starts |
| `run_at_start` | `false` | Also run when the app starts |
| `dry_run` | `false` | Log what would change; push nothing |
| `commit_name` / `commit_email` | see defaults | Commit author identity |
| `signoff` | `true` | Add `Signed-off-by` (DCO) to commits |
Expand All @@ -124,7 +124,7 @@ already live on the box and git history simply resumes.

Everything in your config folder **except** the seeded `.gitignore` exclusions:
`secrets.yaml`, `.storage/`, `.cloud/`, databases, logs, `*.token`,
`.google.token`, `known_devices.yaml`, `ip_bans.yaml`, and the add-on's own
`.google.token`, `known_devices.yaml`, `ip_bans.yaml`, and the app's own
status/log files.

## Status in Home Assistant
Expand Down
10 changes: 5 additions & 5 deletions gitops_backup/rootfs/app/gitops_backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ OZW_Log.txt
.uuid
.ha_run.lock

# This addon's runtime metadata (committing it creates a PR feedback loop)
# This app's runtime metadata (committing it creates a PR feedback loop)
.gitops_backup_status
gitops_backup.log

Expand All @@ -121,7 +121,7 @@ EOF
}

never_commit_patterns() {
# Secrets, credentials, and the addon's own runtime metadata — must never be
# Secrets, credentials, and the app's own runtime metadata — must never be
# committed. Shared by the exclude writer and the untracker.
cat <<'EOF'
secrets.yaml
Expand All @@ -139,7 +139,7 @@ EOF

write_secret_excludes() {
# Write the never-commit patterns to .git/info/exclude (git's local,
# un-shared ignore) so secrets/credentials + addon metadata stay out of
# un-shared ignore) so secrets/credentials + app metadata stay out of
# backups even when migrating a repo that already has its own .gitignore
# (seed_gitignore only writes one for a fresh repo). Called before the stash
# so untracked secrets are never swept in. Idempotent: replace our block.
Expand Down Expand Up @@ -177,7 +177,7 @@ bootstrap_if_needed() {
# Returns 0 if bootstrap ran (caller should stop), 1 if repo already exists
if [ -d .git ]; then return 1; fi
log "No git repo in ${CONFIG_DIR} — bootstrapping"
log "⚠ This add-on versions DECLARATIVE config only. It does NOT back up secrets.yaml, .storage/ (UI-configured integrations, logins, device/entity registries, dashboards), the database, or add-on data — those are excluded on purpose. For a 100% restore you MUST also run HA full backups (e.g. the Google Drive Backup add-on). See this add-on's DOCS → 'What is backed up where'."
log "⚠ This app versions DECLARATIVE config only. It does NOT back up secrets.yaml, .storage/ (UI-configured integrations, logins, device/entity registries, dashboards), the database, or app data — those are excluded on purpose. For a 100% restore you MUST also run HA full backups (e.g. the Google Drive Backup app). See this app's DOCS → 'What is backed up where'."
if [ "$DRY_RUN" = "true" ]; then
seed_gitignore
log "DRY RUN: would git init, commit initial import, push to ${BASE_BRANCH}"
Expand Down Expand Up @@ -295,7 +295,7 @@ main() {
local pr_url
if pr_url=$(create_pull_request "$branch_name" \
"chore: auto backup $(date '+%Y-%m-%d %H:%M')" \
"Automated Home Assistant config backup — ${changed_count} changed file(s). Opened by the GitOps Config Backup addon; review and merge.") \
"Automated Home Assistant config backup — ${changed_count} changed file(s). Opened by the GitOps Config Backup app; review and merge.") \
&& [ -n "$pr_url" ]; then
write_status success pr_created "$pr_url"
log "✅ Backup complete — PR opened: ${pr_url}"
Expand Down
2 changes: 1 addition & 1 deletion gitops_backup/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ configuration:
description: Hours between backup runs.
run_at_start:
name: Run at start
description: Also run a backup when the add-on starts.
description: Also run a backup when the app starts.
dry_run:
name: Dry run
description: Log what would change without pushing anything.
Expand Down
8 changes: 4 additions & 4 deletions tests/self_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ git -c safe.bareRepository=all -C "$TMP/origin.git" branch | grep -q "auto-backu
status | grep -q "warning:branch_pushed" || { echo "FAIL: drift status = $(status)"; exit 1; }
git -C "$TMP/config" branch --show-current | grep -q "^main$" || { echo "FAIL: not back on main"; exit 1; }

# 4. Migration case: repo already had its own .gitignore lacking the addon's
# entries, and was never excluded. The addon's own status file + log AND any
# 4. Migration case: repo already had its own .gitignore lacking the app's
# entries, and was never excluded. The app's own status file + log AND any
# secrets/credentials must be kept out of the backup — even an already-
# committed secret must be untracked (regression test for seed_gitignore
# skip-when-.gitignore-exists + secret-leak on migrated repos).
sleep 1 # ensure a distinct auto-backup/<timestamp> branch name
rm -f "$TMP/config/.git/info/exclude" # never-excluded repo
printf 'secrets.yaml\n' > "$TMP/config/.gitignore" # user ignore, no addon/token entries
printf 'secrets.yaml\n' > "$TMP/config/.gitignore" # user ignore, no app/token entries
HOME="$TMP" git -C "$TMP/config" add .gitignore
HOME="$TMP" git -C "$TMP/config" commit -q -m "user gitignore"
echo "success:no_changes:" > "$TMP/config/.gitops_backup_status"
Expand All @@ -84,7 +84,7 @@ newbranch=$(git -c safe.bareRepository=all -C "$TMP/origin.git" branch \
| grep -o 'auto-backup/[^ ]*' | tail -1)
tree=$(git -c safe.bareRepository=all -C "$TMP/origin.git" ls-tree -r --name-only "$newbranch")
if echo "$tree" | grep -qE '^\.gitops_backup_status$|^gitops_backup\.log$'; then
echo "FAIL: addon runtime files committed to backup branch"; exit 1
echo "FAIL: app runtime files committed to backup branch"; exit 1
fi
if echo "$tree" | grep -qE '^\.google\.token$|^leaked\.key$|^secrets\.yaml$'; then
echo "FAIL: a secret/credential was committed to the backup branch"; exit 1
Expand Down
Loading