Contributing¶
This page covers the development workflow for maintaining this repository: dependency management, commit conventions, and the release process.
Generating Random SOPS Secrets¶
For app-owned passwords, passphrases, and tokens, set the initial dotenv value to the literal GENERATE and encrypt the template. Keep user-supplied values such as OAuth or SMTP credentials out of the helper arguments, and reference existing shared secrets rather than generating duplicates.
The variables must already exist in services/<app>/secret.sops.env, and that dotenv file must already be SOPS-encrypted. With a usable age key configured, run:
bash scripts/generate-sops-secrets.sh services/<app>/secret.sops.env VARIABLE=BYTE_COUNT [VARIABLE=BYTE_COUNT ...]
The helper is generate-once bootstrap only. It generates a cryptographically secure hexadecimal value only when a requested variable exists and its decrypted value is exactly GENERATE. Every existing non-sentinel value is preserved. When nothing needs generation, it exits successfully without rewriting the file.
Do not run the helper concurrently against the same encrypted file. Rotate existing values manually:
Never commit CHANGE_ME placeholders for generated secrets.
Recommended age key setup: 1Password CLI¶
Use SOPS-native SOPS_AGE_KEY_CMD so the private key remains in 1Password:
- Install the 1Password CLI (
op) for the local platform. - In 1Password Desktop, enable Settings > Developer > Integrate with 1Password CLI.
- Sign in to 1Password Desktop, unlock the app, and approve any CLI authorization prompt.
- Verify that
opis available and authenticated without printing any secret:
- Configure the secret reference in the current shell. Every angle-bracket component is a placeholder that must be replaced:
SOPS invokes this command internally when it needs the key. Do not invoke or
shell-evaluate SOPS_AGE_KEY_CMD yourself: SOPS tokenizes it and directly
executes the resulting argument vector, while a shell would interpret
metacharacters differently. Never print or log the command output. Keep the
1Password item and field narrowly targeted to the required Age key.
Before generating or editing, verify that the selected identity can decrypt the target while discarding stdout:
target='services/<app>/secret.sops.env'
mise exec -- sops decrypt --input-type dotenv --output-type dotenv "${target}" >/dev/null
A normal multiline Age identity file is accepted: comment lines may surround
one supported identity. A password field flattened into one line beginning with
# is invalid because the private key becomes part of the comment. Store only
the AGE-SECRET-KEY-... line or preserve real newlines in a Secure Note.
Alternatively, create a local sops.op.env file (the *.op.env pattern is gitignored) containing only an SOPS_AGE_KEY=op://<vault>/<item>/<field> reference, then inject it for one command:
op run --env-file sops.op.env -- bash scripts/generate-sops-secrets.sh services/<app>/secret.sops.env VARIABLE=BYTE_COUNT
If the key cannot be retrieved or used to decrypt the template, the helper stops with actionable guidance.
Safe human review¶
Open the encrypted target through SOPS with VS Code:
target='services/<app>/secret.sops.env'
SOPS_EDITOR='code --wait' mise exec -- sops edit "${target}"
Replace remaining sentinels and required user-supplied or shared values in the editor. Save and close the file; SOPS then re-encrypts it. Never redirect decrypted output to a plaintext file.
Check encryption status, reject unresolved sentinels without printing plaintext, review the Git status, scan the working tree for leaks, and confirm that no generated temporary files remain:
status="$(mise exec -- sops filestatus "${target}")"
printf '%s\n' "${status}" | grep -Eq '"encrypted"[[:space:]]*:[[:space:]]*true' || {
printf '%s\n' 'ERROR: target is not SOPS-encrypted' >&2
exit 1
}
unset status
set +e
mise exec -- sops decrypt --input-type dotenv --output-type dotenv "${target}" |
awk -F= '$2 == "GENERATE" || $2 == "CHANGE_ME" { found = 1 } END { exit found ? 42 : 0 }'
pipeline_status=("${PIPESTATUS[@]}")
set -e
if ((pipeline_status[0] != 0)); then
printf '%s\n' 'ERROR: target cannot be decrypted' >&2
exit 1
fi
if ((pipeline_status[1] == 42)); then
printf '%s\n' 'ERROR: unresolved secret sentinel' >&2
exit 1
fi
if ((pipeline_status[1] != 0)); then
printf '%s\n' 'ERROR: sentinel validation failed' >&2
exit 1
fi
unset pipeline_status
git status --short -- "${target}"
mise exec -- gitleaks dir --redact .
leftover="$(
find "$(dirname "${target}")" -maxdepth 1 -type f \
-name "$(basename "${target}").generated.*" -print -quit
)"
test -z "${leftover}" || {
printf 'ERROR: generated temporary file remains: %s\n' "${leftover}" >&2
exit 1
}
unset leftover
Optional key-file fallback¶
Use SOPS_AGE_KEY_FILE only when 1Password CLI is unavailable. Without that variable, SOPS checks %APPDATA%\sops\age\keys.txt on Windows or ${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt on Linux/WSL. Do not print, log, or read the private key through scripts.
For an already provisioned Windows fallback, restrict the key file ACL to the current user:
$keyFile = if ($env:SOPS_AGE_KEY_FILE) {
$env:SOPS_AGE_KEY_FILE
} else {
Join-Path $env:APPDATA 'sops\age\keys.txt'
}
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -Path $keyFile -AclObject $acl
For an already provisioned Linux/WSL fallback, restrict the key file to mode 600:
key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}"
chmod 600 "$key_file"
Renovate¶
Dependency updates are managed by Renovate. Only renovate.json5 lives in this repository — every other file below is a remote preset in DevSecNinja/.github, pulled in through the extends list. There is no .renovate/ directory here.
| File | Purpose |
|---|---|
renovate.json5 |
Root config — global settings, extends index, repo-local creation schedule, concurrency cap and digest exceptions |
github>DevSecNinja/.github//.renovate/autoMerge.json5 |
Auto-merge policy (pin, pinDigest, digest, minor, patch) |
github>DevSecNinja/.github//.renovate/base.json5 |
Shared baseline settings common to all DevSecNinja repositories |
github>DevSecNinja/.github//.renovate/customManagers.json5 |
Regex managers for SOPS version, mise min_version, workflow versions |
github>DevSecNinja/.github//.renovate/groups.json5 |
Grouped updates (postgres, mise) |
github>DevSecNinja/.github//.renovate/labels.json5 |
PR labels by update type and datasource |
github>DevSecNinja/.github//.renovate/packageRules.json5 |
Release age gates, non-Docker-Hub registry gating, stale flag, linuxserver versioning |
github>DevSecNinja/.github//.renovate/semanticCommits.json5 |
Scoped commit messages with version arrows |
Update timing policy¶
The root-level schedule: ["at any time"] in renovate.json5 overrides the shared base.json5 creation window of ["every weekend", "on Friday"] in timezone Europe/Amsterdam. Eligible update candidates may be created on normal hosted Renovate runs any day. Hosted run frequency is unchanged.
The repository-local capacity change sets root-level prConcurrentLimit: 20. branchConcurrentLimit remains unset and inherits prConcurrentLimit, raising the effective branch/PR cap from the default 10 to 20. More cooldown candidates can start soaking concurrently, but prHourlyLimit remains at its inherited default of 2: creation is still limited to two PRs per hour, subject to hosted run frequency and queue bottlenecks.
This is a capacity-only change: the exact five-package rebaseWhen: "never" frozen cohort, grouping, major manual merge policy, and all automerge/digest policies remain unchanged. Increased capacity does not bypass age gates or required checks, fix existing failing checks, reset existing HEADs, or guarantee immediate clearance of the entire backlog.
Creation timing is separate from merge eligibility. Native minimumReleaseAge checks remain in place where configured and trusted release timestamps are available. Independently, the required pr-cooldown check on renovate/docker-gated-* branches still waits 14 days from the HEAD committer time. The age policies are:
| Update type | Manager / datasource | Minimum age | Merge |
|---|---|---|---|
| minor / patch | actions/* GitHub Actions |
3 days | Auto-merged |
| minor / patch | All other GitHub Actions | 14 days | Auto-merged |
| minor / patch | Docker images | 14 days | Auto-merged |
| minor / patch | GitHub Releases | 14 days | Auto-merged |
| minor / patch | mise tools |
14 days | Auto-merged |
| digest | Docker images pinned to :latest / :beta or explicitly matched rolling tags |
0 (native; see below) | Auto-merged |
| digest | GitHub Actions refs pinned to main |
14 days | Auto-merged |
| major | Everything | 14 days | Manual merge |
The existing Docker mutable-channel and rolling-tag digest rules set minimumReleaseAge: "0". Docker Hub digest updates covered by these rules are outside the Docker-gated scope and can flow sooner, subject to other required checks and queue limits; not every update waits 14 days. This native age exception does not bypass pr-cooldown on Docker-gated branches.
Auto-merged PRs require CI to pass and carry the [automerge] commit-message suffix. Most rules use automergeType: "pr" with platformAutomerge: true, so GitHub merges the PR itself once every required check is green, regardless of the creation window; Renovate's automergeSchedule is not enforced with platform automerge. The 3-day actions/* exception uses automergeType: "branch" (direct push, no PR). Major updates always require a manual merge, regardless of datasource.
Digest-only updates are disabled by the shared preset to reduce PR noise and to avoid auto-merging a hijacked mutable tag. This repository re-enables them in renovate.json5 for mutable Docker channels (:latest / :beta), explicitly matched rolling Docker version tags, and GitHub Actions / reusable-workflow refs pinned to main (#636).
Enforcing the soak without a trusted timestamp¶
Renovate only derives a release timestamp for Docker images from Docker Hub — it reads tag_last_pushed from the hub.docker.com API, which is Hub-specific and not part of the OCI specification. The standard Registry V2 /tags/list endpoint every other registry serves returns tag names only, and Renovate will not fall back to the OCI org.opencontainers.image.created label because the publisher controls it and could forge it to skip the soak.
The 14-day soak is still enforced for those images — just by a different mechanism:
- A rule in
packageRules.json5matches Docker dependencies whose package name carries an explicit registry host other than Docker Hub (/^[^/]*\./combined with!/^docker\.io\//). - That rule sets
minimumReleaseAgeBehaviour: "timestamp-optional"— so Renovate can open the PR on a normal hosted run instead of blocking on a timestamp it cannot obtain — and stamps the branch viaadditionalBranchPrefix: "docker-gated-". .github/workflows/renovate-pr-cooldown.ymlcalls the shared reusable workflow, which posts the requiredpr-cooldownstatus check on thoserenovate/docker-gated-*branches. The check stays pending until the PR branch HEAD committer date is at least 14 days old, with scheduled reevaluation every six hours. Eligible PRs can then merge via GitHub auto-merge once all required checks pass; major updates still require manual merge.
Because the same rule sets both the behaviour and the branch prefix, and the workflow gates exactly that prefix, the Renovate config and the gate cannot drift apart.
Two consequences worth knowing:
- There is no registry allowlist (removed in
DevSecNinja/.githubPR #312). Any registry nobody has explicitly configured is gated by default, which is fail-safe. Under the previous allowlist a new registry silently got nothing —dhi.iowas missing for roughly 3.5 months and those images received no updates at all, including security updates (#634). - Bare Docker Hub names (e.g.
nginx,library/nginx) use native release-age rules and are not Docker-gated. This repository mandates an explicit registry prefix on every image, so bare names should not appear here anyway.
Background: ADR 0005 in DevSecNinja/.github (docs/design-decisions/0005-pr-age-cooldown-for-untrusted-timestamps.md), which classifies pr-cooldown as a load-bearing control. For why docker.io is preferred when the same image is available on several registries, see Architecture § Image Selection: Registry Preference.
Homepage frozen-candidate pilot¶
The Homepage pilot now covers an exact five-package cohort, expanded in #793. The local renovate.json5 rule Freeze Homepage, Traefik, Immich app, and Home Assistant update candidates while they complete the cooldown sets rebaseWhen: "never" for datasource docker, overriding the shared DevSecNinja/.github setting rebaseWhen: "conflicted" only for:
- Home Assistant:
ghcr.io/home-assistant/home-assistant— churn on the existing:betachannel is intentionally included as a low-impact rolling-digest stress test because the container is not actively used. - Homepage:
ghcr.io/gethomepage/homepage— single-image update coverage, continuing the original pilot. - Immich:
ghcr.io/immich-app/immich-machine-learningandghcr.io/immich-app/immich-server— grouped app-image coverage, preserving the existing Immich update group. - Traefik:
dhi.io/traefik— single-image update coverage for important infrastructure.
Matching is by exact package name, not by stack or registry. Immich Postgres (ghcr.io/immich-app/postgres), dhi.io/redis, all other sidecars, AdGuard Home, Bitwarden, and ESPHome are excluded. The aim is to let existing candidates finish their soak instead of repeatedly restarting it as newer candidates appear.
- Existing branch: Ordinary automatic Renovate version/digest rewrites to the same existing branch stop. This freezes that branch's candidate; it is not a per-digest queue. Separate major-update branches and stale-branch cleanup remain normal.
- Cooldown: The independent required
pr-cooldowncheck onrenovate/docker-gated-*branches still waits 14 days from the HEAD committer date and reevaluates every six hours. Expanding the rule does not rewrite existing HEADs or reset their elapsed age; current candidates retain time already accrued. - Mutable channels: Digest updates for
:latest/:betaremain enabled withminimumReleaseAge: "0". That existing Renovate setting is unchanged and does not bypass the independent Docker-gated cooldown. - Merge: GitHub
platformAutomergeis already enabled. Non-strict up-to-date protection allows an eligible PR that is behind the base branch but has no conflicts to merge once all required checks pass, without rebasing merely to catch up. - Next candidate: After merge, a normal hosted Renovate run may propose the latest candidate on any day, subject to the rate/concurrency limits described above and queue bottlenecks. The new candidate starts a fresh soak. Intermediate versions/digests are not queued individually.
Operator intervention: Conflicts, failing builds, and known-bad releases need an operator; the frozen branch will not automatically repair them. Disable automerge for a known-bad candidate before diagnosing it so a passing cooldown cannot cause an unwanted merge. In particular, Traefik is important infrastructure: retain manual rejection or refresh when a candidate is known-bad or a newer release contains a needed security fix. This policy does not force unsafe candidates to finish soaking or merge.
Rebasing can replace the candidate and restart the cooldown
Resolving conflicts manually or requesting a Renovate rebase from the PR or Dependency Dashboard, including Rebase all open PRs, can refresh the candidate and reset its HEAD age. Even a manual Git rebase that preserves the image target restarts this gate by writing a new committer date. Rebase deliberately, then review the resulting target and required checks; automatic conflict resolution is not promised.
Observed first cycle: Homepage #722 retained HEAD 6d3dfe8 (2026-09-10 02:32:56 UTC) and candidate v2.3.0@sha256:f820276654539cdc2cf0169f28188d135919a7984fad76d83d8d5ff1383f3705 through the newer v2.4.0 release on 2026-09-17. After 55 pending cooldown status posts, the check succeeded on 2026-09-24 at 04:48:54 UTC and automerge followed at 04:49:22 UTC. This demonstrates one freeze/soak/automerge cycle. As of 2026-09-28, the next v2.4.0 candidate was only Rate-Limited in dashboard #115; no next PR or new soak had been observed.
Scope and rollback: The original frozen-cohort expansion was repository-local, not a global or shared-config change. It included no workflow, cooldown algorithm/duration, branch naming, grouping, automerge, rate-limit, digest-enabling, or image-pin changes. To roll back the freeze, remove the local freeze rule from renovate.json5; all five packages then inherit the shared rebaseWhen: "conflicted" behavior again.
Broader rollout tracking remains open in #758; related: #759.
References: Renovate rebaseWhen and renovatebot/renovate#26294.
Silently skipped dependencies (dhi.io)¶
Docker Hardened Images prune entire minor lines. When the tag a compose file is pinned to stops existing, getCurrentVersion returns null (the shared preset uses rangeStrategy: "pin") and Renovate marks the dependency skipReason: invalid-value. The dependency dashboard filters skipped dependencies out, so the image disappears from the dashboard with no warning and silently stops receiving updates. This is how four hardened images went roughly 3.5 months without any updates (#634).
Symptom
A dhi.io image that used to appear on the dependency dashboard and no longer does is not up to date — it is unparseable. Check the catalog and re-pin the compose file to a tag that still exists.
Rule precedence note¶
packageRules are applied in the order they appear across all extends entries — last matching rule wins for each property. autoMerge.json5 is loaded before packageRules.json5, so packageRules.json5 must not contain a matchManagers: ["github-actions"] timing rule or it would override the 3-day exception for actions/*.
Commit Message Convention¶
All commits follow the Conventional Commits specification:
Common types: feat, fix, chore, docs, refactor, ci. The scope is typically the service folder name (e.g. feat(immich):, fix(traefik):). Compliance is enforced locally by a lefthook commit-msg hook using cog verify.
Release Process¶
Releases are version-tagged on main and automatically published as GitHub Releases via a CI workflow.
Creating a release¶
# Bump the minor version (updates CHANGELOG.md, commits, tags, and pushes)
cog bump --minor
# Or patch for bug-fix releases
cog bump --patch
# Dry-run to preview the next version without making changes
cog bump --minor --dry-run
cog bump orchestrates the full release:
- Calculates the next semver version from conventional commits since the previous tag
- Runs
git-cliff --tag <version> --output CHANGELOG.mdto regenerate the full changelog - Runs
dprint fmt CHANGELOG.mdto ensure the changelog passes CI formatting checks - Creates a
chore(release): bump version to <version>commit containing the changelog update - Creates the
v<version>git tag - Pushes the commit and tag to
origin
The tag push triggers .github/workflows/release.yml, which runs git-cliff --latest --strip all
to produce release-scoped notes and creates the GitHub Release automatically.
Tools¶
| Tool | Role |
|---|---|
cog |
Version bump, bump commit, git tag, push orchestration |
git-cliff |
Changelog generation (CHANGELOG.md + GitHub Release notes) |
cliff.toml |
Commit grouping, body template, GitHub commit link configuration |
cog.toml |
Bump hooks, tag prefix, merge-commit filtering |
Task Runner¶
go-task provides a unified interface for all repository tasks — testing, linting, formatting, compose validation, deployment, and more. It is managed by mise alongside the other tools.
Verify the installation:
List all available tasks:
Common workflows:
| Command | What it does |
|---|---|
task install |
Install all dependencies (mise tools + BATS libraries) |
task test |
Run the full test suite |
task lint |
Run all linters (YAML, shell, actions, security) |
task format |
Auto-format all files (YAML, shell, Markdown) |
task format:check |
Check formatting without modifying files |
task ci:local |
Run the full CI pipeline locally (format check + lint + compose validate + test) |
task ci:quick |
Quick checks — format and lint only, no tests |
task compose:validate |
Validate all compose files |
task pre-commit |
Run lefthook pre-commit hooks |
task docs:serve |
Live-preview the MkDocs site locally |
Run task help for detailed usage examples.
Testing¶
The repository has a BATS suite for scripts/dccd.sh and
the SOPS random-secret helper, with 214 tests:
| Category | Count | What it tests |
|---|---|---|
| Unit | 142 | DCCD functions and 21 mocked SOPS secret-helper tests |
| Integration | 72 | DCCD workflows and three integration tests using real Age and SOPS |
| E2E | 4 | Real Docker containers for DCCD — skipped locally and run separately |
Running tests¶
task test
task test:unit
task test:integration
task test:e2e
task test:file -- tests/dccd/unit/log_message.bats
E2E tests require Docker and are skipped unless DCCD_E2E=1 is set. The task test:e2e command
sets this automatically.
Pre-commit hook¶
From the repository root, install or regenerate Git hooks after changes to the
launcher in .lefthook.toml:
Generated hooks finish mise install before executing the pinned Lefthook
through mise exec; checks still run in parallel. If tool installation fails,
the checks do not start and the commit is blocked. Ensure mise is on PATH
in the environment that launches Git, including VS Code or other GUI clients.
This ordering prevents parallel checks within the same commit from racing to auto-install tools or repair mise runtime symlinks. It does not serialize independent processes or bypass sandbox restrictions.
For manual checks, use task pre-commit, which performs setup before running
Lefthook, or run:
Direct lefthook run invocations bypass the generated hook launcher and its
setup step.
Lefthook runs both the DCCD and SOPS secret unit suites before every commit. CI and the Taskfile also include both projects' unit and integration tests; DCCD E2E tests run separately with Docker.
The SOPS integration suite creates a fresh temporary Age identity for every test, encrypts a fixture,
runs generate-once replacement through an offline fake op, decrypts and verifies the result, then
reruns the helper to prove no-op idempotency. Failure paths prove the encrypted SHA-256 is unchanged
and leave no generated temporary files. CI never uses a production Age key.
Writing tests¶
See tests/README.md for the full test writing guide — directory structure, helper reference, mock patterns, and conventions.
Per-Service Documentation¶
Each service can have a README.md in its directory (e.g. services/adguard/README.md). These files are the source of truth for service-specific documentation — architecture, access URLs, init containers, secrets, first-run setup, and upgrade notes.
MkDocs integration via symlinks¶
MkDocs can only serve files inside its docs/ directory. To make service READMEs appear in the MkDocs site without duplicating content, the repo uses symlinks:
docs/services/adguard.md → ../../services/adguard/README.md
docs/services/plex.md → ../../services/plex/README.md
A script generates and maintains these symlinks:
When to run it:
- After adding a new service with a
README.md - After retiring a service (stale symlinks are cleaned up automatically)
The symlinks are committed to Git. Git stores them as text files containing the relative target path, so they work across clones on all platforms that support symlinks.
Link paths in service READMEs¶
Because MkDocs resolves links relative to docs/services/ (the symlink location), cross-references to other docs must use paths relative to that directory — not relative to services/<app>/. For example, use [Infrastructure](../INFRASTRUCTURE.md) (not ../../docs/INFRASTRUCTURE.md). These links work in MkDocs strict mode; on GitHub the README content is still readable even though the relative link won't resolve from the services/<app>/ path.
Adding a new service to MkDocs¶
- Create
services/<app>/README.md - Run
bash scripts/generate-docs-symlinks.sh - Add the entry to the
Services:section inmkdocs.yml(alphabetical order by display name):
- Commit the symlink and
mkdocs.ymlchange together