Changelog
Notable changes to gtcnsl, newest first. Format follows Keep a Changelog; versions follow Semantic Versioning.
Keep a Changelog · SemVer
v1.3.0
2026-07-18
Added
- **Gitea 1.27.x and Runner 2.x are verified and supported, and `--reencrypt` is now gated by a version envelope.** The SECRET_KEY re-encryption crypto (`internal/gitea/secretcrypto`/`secretdb`) was re-verified byte-for-byte against the v1.27.0 Gitea sources — both schemes, all five encrypted sites, no new call sites — and the embedded config-schema baseline moved from 1.26.2 to 1.27.0 (9 sections unchanged, 764 → 772 keys); `scripts/update-gitea-schema`, which `docs/ARCHITECTURE.md` had referenced since ADR 0015 without it ever existing, now actually does. That re-encryption crypto is not a public Gitea contract — an upstream minor could change it without a changelog mention — so `secrets rotate SECRET_KEY --reencrypt` now hard-refuses, before touching anything, against any Gitea minor outside the verified envelope (currently `[1.24, 1.27]`) rather than risk a repeat of the silent-corruption incident v1.2.0 grew from; the explicit, separate override is `--reencrypt-unverified-version-i-understand` (ADR 0028). COMPATIBILITY.md now states the supported upstream range outright (Gitea through 1.27.x, Runner ≥1.0 incl. 2.x); Runner 2.x's breaking change (implicit `DOCKER_USERNAME`/`DOCKER_PASSWORD` env vars dropped) does not affect gtcnsl-generated configs.
- **`gtcnsl runner register`/`runner reconfigure --token-file <path>`.** New flag, mutually exclusive with `--token`: gtcnsl reads and trims the file itself, and on a detected `gitea-runner` >= 2.1.0 (the release that added upstream `--token-file` support) never places the token in argv — it writes it to a gtcnsl-owned 0600 temp file inside the runner's WorkDir, passes `--token-file` to the `gitea-runner register` subprocess, and removes the temp file regardless of outcome. A token in argv is readable by anyone on the host for the process's lifetime (`/proc/<pid>/cmdline`, `ps`); this closes that window for runners new enough to support it. Runners below 2.1.0 keep the pre-existing `--token` argv behaviour — a documented fallback, not a silent failure. The dind-rootless executor was already env-file-based (ADR 0022) and is unaffected either way (ADR 0030).
- **`gtcnsl runner register`/`reconfigure` now generate a baseline `config.yaml`, and `gtcnsl runner config generate [--force]` (re)writes it on demand.** Runner 2.x added a batch of new config.yaml keys (`runner.post_task_script`, `action_shallow_clone`, `container.network_create_options.enable_ipv4/6`, and more) that gtcnsl previously gave no way to see or set — `runner register` had only ever written `.runner`, so every installed runner ran on gitea-runner's undocumented built-in defaults. Registration/reconfiguration now write a baseline via the installed binary's own `generate-config` subcommand (atomic, 0640, chowned) — but only if `config.yaml` isn't already there; an existing file (operator-edited or from a previous register) is never touched. `gitea-runner.service`'s ExecStart gains `--config <WorkDir>/config.yaml`, but strictly monotonically: only once that file is confirmed present at unit-render time, so an in-place upgrade of a pre-this-feature install can never point the unit at a file that doesn't exist yet (ADR 0031). Declarative apply of individual config.yaml keys is deliberately deferred; `runner config` is the placeholder parent command for it.
- **`gtcnsl gitea upgrade [--to <v>] [--yes]` and `gtcnsl runner upgrade [--to <v>] [--yes]`** — the `upgrade` verb README.md and COMPATIBILITY.md already documented, now wired as real commands (ADR 0035). Both are thin wrappers, not new machinery: `gitea upgrade` refuses, before touching anything, when there is nothing installed yet, otherwise delegates the entire flow to the same in-place-upgrade path `gitea install --to <version>` has used since feature 0067 (live paths, foreign-unit preservation, health check); `runner upgrade` wires the CLI straight onto `internal/core.UpgradeRunner` (feature 0014), whose preflight already refused an absent install and which already follows a foreign or legacy-named unit's live binary path (feature 0080, ADR 0034). Flags are deliberately minimal (`--to`/`--yes` only) — `gitea install --to` remains the fuller-flagged path for `--data-dir`/`--rewrite-unit`/`--serving` during an in-place upgrade.
- **`gtcnsl runner adopt` — bring an existing Gitea Actions Runner under management, never re-registering it.** The runner twin of `gitea adopt` (ADR 0032): registration tokens are typically one-time-use, so unlike `runner reconfigure` this never touches `.runner` beyond tightening its mode to 0640 when it is looser (the file also carries the registration token — never logged, never rewritten, never moved). Dry-run by default; `--yes` applies. Recognizes both the current `gitea-runner.service` naming and the legacy pre-rename `act_runner.service` naming (ADR 0013/0018), reading the binary/work-dir/user straight from the live unit's own directives rather than gtcnsl's defaults — and — thanks to this same release's unified live-unit inspection (ADR 0034, see Fixed below) — a later `runner install --to`/`runner upgrade` recognizes the adopted install and acts on those same live paths. A unit gtcnsl did not write is preserved byte-for-byte by default (ADR 0025); `--rewrite-unit` replaces its content with gtcnsl's template rendered from the live values, keeping its existing filename (no renaming). dind-rootless installs are refused as out of scope (ADR 0022).
- **Runner state now travels with `gtcnsl backup`/`restore`.** `.runner` registration tokens are typically one-time-use, so losing the file used to mean a fresh token and a lost runner UUID. `backup` now additionally, and optionally (Warning + skip when absent, same as `secrets.ini`/`custom/` today), captures `<WorkDir>/.runner` (secret-class — its token never appears in events/logs), `config.yaml`, the `gitea-runner.service` unit (read through the injected systemd manager, never a hardcoded filesystem path), and dind-rootless's `registration.env` — the last with an explicit warning and manifest note that it alone cannot fully restore a dind-rootless runner (the `gitea-runner-data` Docker volume stays out of scope, ADR 0022). The manifest schema bumps 1 → 2; a schema-1 backup restores exactly as before, and an old gtcnsl still refuses a schema-2 manifest. `restore` stops `gitea-runner.service` before applying its files and starts it again only after Gitea's own health check passes, tolerating "unit not installed"/"not active" as a Warning rather than a Failed (ADR 0033).
Fixed
- **`secrets rotate SECRET_KEY` — the rotation guard, `--reencrypt`, and `doctor`'s weak-key probe now honour `SECRET_KEY_URI` end-to-end.** Gitea has supported sourcing `[security] SECRET_KEY` from a file via `SECRET_KEY_URI` since 1.19, but gtcnsl read only the verbatim `SECRET_KEY` value everywhere: on a URI-configured instance the guard and `doctor` silently treated Gitea's public default as "the old key" (a false read), and — worse — a plain rotation wrote the new key into `secrets.ini` without ever touching the file Gitea actually reads, leaving the instance running the *old* key while gtcnsl's own bookkeeping claimed the new one. That is the same silent-divergence failure class the v1.2.0 incident this project grew from, and it made `--reencrypt` on such a host a guaranteed health-check rollback (new ciphertext, old running key). gtcnsl now resolves `SECRET_KEY`/`SECRET_KEY_URI` exactly the way Gitea does at boot (ported line-for-line from `modules/setting/security.go`, fail-closed instead of Gitea's own `log.Fatal` on both-set or an unresolvable URI) and, on rotation, atomically rewrites the URI-backed file itself — with its own timestamped backup and rollback in every failure branch — in addition to `secrets.ini` (ADR 0029). A verbatim-configured instance's rotation is unaffected.
- **A failed `gitea upgrade` (or `gitea install --to` over an existing install) now restores the previous Gitea binary instead of leaving the host without one.** The in-place upgrade path snapshots the live binary before swapping it and, on any failure after the swap (unit deploy, service restart, health check), restores it and restarts the service — the same snapshot-based rollback `runner upgrade` already had (ADR 0036, closing the asymmetry ADR 0035 documented as an open trade-off). A pre-existing unit file is also never deleted by a failed upgrade's rollback anymore.
- **`runner install`/`upgrade`/`register`/`reconfigure` now follow the live unit's own facts** — unit name (including the legacy pre-rename `act_runner.service`), binary path and work dir are read from the actual systemd unit instead of assuming gtcnsl's canonical layout (ADR 0034). Before this, an adopted legacy or relocated install was invisible to `runner install --to`, and `--rewrite-unit` would create a second canonical unit next to the existing one. Both the canonical and legacy unit present at once is now a hard, clearly-worded inspection error.
v1.2.1
2026-07-12
Fixed
- **Progress events are throttled at the source.** The downloader reported progress on every ~32 KB read — hundreds of events per file, most carrying the same percent. It now reports only when the integer percent changes (at most ~5/sec when the server doesn't send a size), and always on the final byte.
- **The CLI stops using carriage-return overwrite when stdout is not a terminal.** The self-overwriting `\r` progress line survives verbatim in pipes, CI logs and ssh-without-tty sessions. On a real terminal nothing changes; everywhere else progress prints as plain lines at 10-percentage-point steps plus the final 100%, with no `\r` at all.
- **The TUI updates its progress line in place.** Every progress event used to append a new log line, filling the screen with `download: N%` rows; a same-stage progress event now replaces the open line, and any other event seals it.
v1.2.0
2026-07-12
Added
- **`secrets rotate SECRET_KEY --reencrypt` — the safe rotation.** Stops Gitea, backs up the database (sqlite: a file copy next to the original, `.reencrypt-bak`; mysql/postgres: the single re-encryption transaction is the safety net), writes the new `secrets.ini`/`app.ini`, re-encrypts every affected row — verifying `decrypt(new) == plaintext` for each one *before* writing it — then starts Gitea and health-checks. Any failure rolls everything back automatically: database, `secrets.ini`, `app.ini`, restart. Gitea never observes a new-key/old-ciphertext mismatch. Both Gitea encryption schemes are implemented byte-compatibly (verified against the pinned v1.26.4 source), covering all five encrypted sites. Supported engines: `sqlite3`, `mysql`, `postgres` (`mssql` is refused with a clear error). Re-running is always safe: each run migrates whatever decrypts under the current key and never touches rows it cannot read.
- **`secrets rotate SECRET_KEY --dry-run`** — classifies every encrypted row (would-be-re-encrypted / already-migrated / unreadable) and reports per-site counts and row IDs without changing anything. Doesn't require `--yes`.
- **`--orphan-encrypted-data-i-understand`** — the explicit, informed opt-out for operators who intend to re-issue secrets and re-enroll 2FA by hand.
- **`doctor` warns when `SECRET_KEY` is empty or Gitea's public default.** An empty `[security] SECRET_KEY` means Gitea encrypts with a value baked into its own source code — the data is effectively unprotected. The probe names the safe fix (`secrets rotate SECRET_KEY --reencrypt`) and never prints the operator's actual key.
Changed
- **Behaviour change (safety): `secrets rotate SECRET_KEY` refuses to orphan data.** Previously the rotation applied cleanly and reported success while silently orphaning every encrypted row — the production incident this release grew from. It now hard-stops *before touching any file* when data encrypted under the current key exists, printing per-category counts of what would be lost, and requires either `--reencrypt` (migrate it) or `--orphan-encrypted-data-i-understand` (accept the loss). Rotating `INTERNAL_TOKEN` / `JWT_SECRET` / `LFS_JWT_SECRET` is unchanged. Strictly a changed outcome for an existing invocation — shipped in a minor release deliberately, because the old outcome was data loss.
- **Binary size grew by ~7 MB** (≈19.6 → ≈27 MB): gtcnsl now bundles the same CGO-free database drivers Gitea itself uses (`modernc.org/sqlite`, `go-sql-driver/mysql`, `lib/pq`) to read and re-encrypt the database in-process — plaintext never leaves the gtcnsl process, no client tools needed on the host.
Fixed
- The `secrets` command summary no longer says rotation is "(in v0.4)" — rotation has been shipping since v0.4 actually landed.
v1.1.2
2026-07-08
Fixed
- **Health checks work on sign-in-only instances.** With `REQUIRE_SIGNIN_VIEW = true` the anonymous `/api/v1/version` endpoint answers 403, so every post-restart health check failed and rolled the operation back — the second half of the same production finding v1.1.1 fixed. Liveness pings now use Gitea's unauthenticated `/api/healthz` (load-balancer) endpoint, and version verification degrades to healthz-alive when the version API is hidden behind sign-in — the version is not visible, not wrong, and the binary on disk was checksum-verified before it started. Instances old enough to lack `/api/healthz` fall back to the version endpoint as before.
v1.1.1
2026-07-08
Fixed
- **Health checks against ACME hosts no longer fail falsely.** The config-derived prober dials `127.0.0.1`, and Go sends **no SNI** for IP literals — Gitea's built-in ACME (autocert) selects certificates strictly by server name and answered the handshake with `tls: internal error`, so every post-restart health check on a `https-acme` install failed and triggered a (correct, but unnecessary) rollback — `secrets rotate`, `config apply/set/toggle` and in-place upgrades were all blocked on such hosts. The prober now presents the live `[server] DOMAIN` as the TLS server name while still connecting to loopback. Found live on the project's own production host during the first real `secrets rotate SECRET_KEY` after adopt.
v1.1.0
2026-07-08
Added
- **`gitea install` / `runner install` detect an existing installation.** Both flows now inspect the host first (binary version, unit file, live app.ini) and, over an existing install — even a hand-rolled one with custom paths — switch to in-place upgrade semantics: paths are taken from the **live** install instead of the defaults, an explicit `--data-dir`/`--log-dir` that contradicts the live install is refused ("an upgrade never moves data"), and a unit file gtcnsl did not write is **preserved byte-for-byte** across the upgrade (binary swap + restart only) — the new `--rewrite-unit` flag opts into replacement. The TUI install screen shows the detection as an upgrade banner with the live paths prefilled and refuses contradicting edits inline.
- **`gtcnsl gitea adopt` — bring a hand-rolled install under management, never moving data.** Dry-run by default; `--yes` applies. Extracts the managed secrets from `app.ini` into `secrets.ini` with the **same values** (no restart — this turns doctor's secrets probe green and unlocks `secrets check`/`rotate`), tightens a world-readable `app.ini` to 0640, and `--emit-template` writes a secrets-free `app.ini.tmpl` for the declarative loop. `--unit` additionally normalizes a hand-written `gitea.service` to the managed-base + operator-drop-in canon (ADR 0025), with systemd's own effective properties proving the result behaves identically — any unexpected difference rolls back automatically.
- **The gitea unit template grants `CAP_NET_BIND_SERVICE`.** Fresh installs can point `server.HTTP_PORT` below 1024 (e.g. direct HTTPS on :443 with Gitea's built-in ACME) without hand-editing the unit: `AmbientCapabilities` grants the capability to the non-root service and `CapabilityBoundingSet` drops every other one. Harmless on the default :3000.
- **Serving profiles on `gitea install`.** `--serving behind-proxy` (the default — plain HTTP on :3000, exactly the previous behaviour), `--serving https-acme` (Gitea terminates TLS itself with built-in Let's Encrypt on :443, :80 answering the ACME challenge and redirecting; requires `--domain`, `--acme-email` and the **explicit `--acme-tos` consent**), and `--serving https-manual` (your `--cert-file`/`--key-file`, `--https-port` defaulting to 443). Profile-specific preflights run before anything is downloaded: target ports bindable, certificate material present and readable by the service user, ACME domain resolving to this host (best-effort warning). The TUI install wizard gained the same choice as a step, with the ACME Terms-of-Service consent as an explicit checkbox.
- **`gtcnsl gitea enable-https` — switch a live HTTP install to HTTPS, with rollback.** Rides the declarative config machinery: the `[server]` section is rewritten atomically with a backup, Gitea restarts and is health-checked, and **any failure rolls back to the working HTTP config automatically**. Binding a privileged port on a hand-written unit that lacks `CAP_NET_BIND_SERVICE` is a hard stop that prints the exact directives — `--grant-caps` instead deploys a small gtcnsl-owned drop-in (the operator's unit file is never edited, ADR 0025), which the rollback also revokes. Re-running against an already-https install is a no-op.
- **Doctor probes https/port/capability coherence.** `PROTOCOL = https` on a port below 1024 whose unit does not *effectively* grant `CAP_NET_BIND_SERVICE` (drop-ins counted, systemd asked directly) is reported as a blocker with the fix spelled out — this exact hand-setup would otherwise die at bind time on its next restart, months later.
- The health prober now derives its probe target from the live `[server]` config (scheme, port, a longer first-issuance window when ACME is on) instead of assuming `http://127.0.0.1:3000`, probing loopback TLS with verification off — the certificate is domain-bound; this is liveness only.
v1.0.0
2026-07-01
Added
- **`gtcnsl backup` / `backup list` / `restore`.** Snapshot Gitea's binary, `app.ini`, `secrets.ini`, and the `custom/` tree into a manifest-tagged directory under `/var/lib/gtcnsl/backups/`, list them, and restore one with a safe snapshot → stop → swap → restart → health-check flow that undoes itself if Gitea won't come back. `backup --keep N` prunes older snapshots.
- **`dind-rootless` runner executor.** Run the Gitea Actions Runner in a rootless Docker-in-Docker container managed by systemd, with no runner binary on the host (ADR 0022). Selectable at `runner install --executor=dind-rootless`.
- **Opportunistic Gitea signing-key refresh.** When the embedded release key nears expiry, gtcnsl fetches a fresh copy — pinned to the same fingerprint — from keys.openpgp.org and otherwise falls back to the embedded key, so download verification keeps working across the key's yearly renewal without a new gtcnsl release. Zero network in the common case (ADR 0024).
- **`gtcnsl doctor` now reports boot-enablement** of the `gitea` and `gitea-runner` units, so a missing `systemctl enable` surfaces before a reboot does.
- **Compatibility policy.** [`COMPATIBILITY.md`](COMPATIBILITY.md) codifies the public surface, the SemVer promise, and a deprecate-then-remove policy.
Fixed
- **In-place Gitea upgrade now actually restarts onto the new binary.** `gtcnsl gitea install --to <newer>` over a running install (the documented upgrade path) swapped the binary but issued a plain `systemctl start`, which is a no-op on an already-running unit — the old Gitea kept serving and the install failed its health check reporting the old version. It now restarts the unit, so an upgrade replaces the running instance. Surfaced by the new container upgrade acceptance scenario (the fake systemd manager's start always "succeeded", hiding the gap from unit tests).
- **Units are enabled for boot persistence.** `gitea` and `gitea-runner` are now `systemctl enable`d, so they come back after a reboot instead of staying down.
- **Version listing uses the `dl.gitea.com` mirror**, not the retired `gitea.com` API endpoint that had begun returning 500s and broke the TUI/CLI version picker.
- **Refreshed the embedded Gitea (Teabot) signing key** whose signing subkey had expired (2026-06-23), which had stopped GPG verification of downloaded Gitea binaries. Renewed to expire 2027-06-26; same pinned fingerprint.
v0.6.3
2026-05-30
Fixed
- The release pipeline now uploads each binary from its real build path and refuses to publish a missing or empty file — the root cause of the empty downloads across v0.6.0–v0.6.2.
v0.6.2
2026-05-30
Fixed
- gtcnsl --version now exists and prints the same line as gtcnsl version.
- self-update no longer always rolls back — its post-swap version check was failing because the --version flag was missing.
- Release binaries report their version with the leading v, fixing the self-update "already latest" and tag-match checks.
v0.6.1
2026-05-30
Fixed
- The mirror step no longer relies on a shell feature missing from the release image, so binaries actually reach dl.gtcnsl.com (v0.6.0 published on Gitea but was never mirrored).
v0.6.0
2026-05-30
Added
- gtcnsl self-update replaces the running binary with the latest (or a specific --to vX.Y.Z) release from dl.gtcnsl.com: download → verify SHA-256 → atomic swap → confirm it runs → roll back on any failure. Flags: --dry-run, --rollback, --yes, --dl-host.
- Discovery and download read a versions.json manifest from the download host and never call the Git API, so self-update survives the host going private.
- A Self-update screen in the TUI (Check / Apply / Rollback) driving the same flow as the CLI.
- The release workflow mirrors every published binary + checksums.txt to the download host (the Gitea release stays the source of truth).
Changed
- Release binaries are now named gtcnsl-vX.Y.Z-linux-<arch> (with the leading v), matching the download host’s contract.
v0.5.0
2026-05-24
Added
- Interactive TUI: run gtcnsl with no arguments to drive Gitea, Runner, Config, Secrets and Doctor from a menu — every v0.1–v0.4 operation, with colour-coded logs, scrollable output and a spinner.
- gtcnsl config get / set / toggle — read or change a single app.ini key, with the same backup → atomic write → restart → health-check rollback as the declarative flow. Secret and compound keys are refused with a redirect.
- gtcnsl doctor — host pre-flight checks (systemd, outbound HTTPS, disk, root) plus installed-Gitea and runner checks; --fix --yes auto-remediates common problems (missing ca-certificates, missing secrets).
- A config schema catalog backs the config screens with defaults, types and key descriptions; gtcnsl config sync-schema fetches a newer Gitea’s schema.
Fixed
- Config and secrets writes now preserve file ownership after the atomic rename, so Gitea can still read its own config after an apply (previously the file could be left root-owned).
- doctor --fix now re-checks outbound HTTPS correctly within the same run after installing ca-certificates.
v0.4.1
2026-05-23
Added
- Every release is mirrored to S3-compatible object storage alongside the Gitea release, with a /latest/ prefix and a versions.json discovery manifest for the upcoming self-update.
- Pre-release tags (-rc / -beta / -alpha) get a pinned upload but are kept out of /latest/ and versions.json, so a candidate never becomes the default install target.
v0.4.0
2026-05-23
Added
- gtcnsl config apply --template — render and apply a declarative app.ini with ${VAR} substitution, a diff, a backup, a restart and automatic rollback on health-check failure.
- Managed secrets: gtcnsl secrets generate / check / rotate for Gitea’s four core secrets in /etc/gitea/secrets.ini; rotate re-applies config and rolls back both files on failure.
- An embedded Gitea config-schema catalog (defaults, types, descriptions), with gtcnsl config sync-schema to fetch a newer version.
- gtcnsl runner install --executor=podman (Tier 3, experimental).
v0.3.1
2026-05-23
Added
- Container egress and disk-space health probes during runner setup, with copy-paste fixes on failure (non-fatal warnings).
- gtcnsl runner reconfigure --admin-token cleans up the orphaned Gitea-side runner registration after a re-register.
- docs/VPS-CHECKLIST.md — an operator checklist for setting up a gtcnsl-managed runner on a VPS.
v0.3.0
2026-05-23
Added
- gtcnsl gitea install / upgrade — install the latest Gitea (GPG + SHA-256 verified) with a hardened systemd unit and a post-start health check; atomic version upgrades with rollback on failure.
- gtcnsl runner install / register / reconfigure / upgrade for act_runner, with host, docker and docker-rootless executors.
- Per-distro package-manager support (apt / dnf) with pre-existing-Docker detection.
- A single static binary for amd64, arm64 and armv7, distributed through the project’s own Gitea releases.