Skip to content
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ This app manages Git-backed **source/configuration** for Compose stacks. It is *

## Current phase

**PR #5 — Deployment routing** is the next active product phase (DeploymentProvider, Portainer/generic webhooks,
forge webhook receiver, relevance rules, selective deploy). PR #2 (application foundation), PR #3 (source workspace)
and PR #4 (Git workflow: commit, safe push, conflict protection, stack history, Changes UI; released in v0.5.0) are
done. Do not build secrets providers, runtime watch or PWA work ahead of their phases.
**PR #5 — Deployment routing** (DeploymentProvider, Portainer/generic webhooks, forge webhook receiver, relevance
rules, selective deploy) is implemented on `main` for v0.6.0 and is in its release gate (#57; release PR #41).
PR #2 (application foundation), PR #3 (source workspace) and PR #4 (Git workflow: commit, safe push, conflict
protection, stack history, Changes UI; released in v0.5.0) are done. The maintainer picks the next phase from the
roadmap. Do not build secrets providers, runtime watch or PWA work ahead of their phases.

See [docs/public/plans/MVP_PLAN.md](docs/public/plans/MVP_PLAN.md) and the code layout in
[docs/public/ARCHITECTURE.md](docs/public/ARCHITECTURE.md#code-layout-implemented-in-pr-2-extended-through-v050).
Expand Down
4 changes: 3 additions & 1 deletion docs/internal/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ are listed in [manifest.json](../manifest.json) (see [docs-sync](../../.github/d
| [AUTH_AND_CREDENTIALS.md](../public/AUTH_AND_CREDENTIALS.md) | Auth, encryption at rest |
| [SECURITY.md](../public/SECURITY.md) | Threat model and controls |
| [UX.md](UX.md) | Desktop / mobile / PWA |
| [BACKUP_RESTORE.md](../public/BACKUP_RESTORE.md) | Backup and restore |
| [BACKUP_RESTORE.md](../public/BACKUP_RESTORE.md) | Backup, restore and rollback |
| [UPGRADING_0_6.md](../public/UPGRADING_0_6.md) | v0.6.0 upgrade notes and operator acceptance checklist |
| [releases/v0.6.0-readiness.md](releases/v0.6.0-readiness.md) | v0.6.0 release audit: verification matrix, findings, risks, Go/No-Go |
| [TESTING.md](../public/TESTING.md) | Testing strategy |

## Architecture decision records
Expand Down
103 changes: 103 additions & 0 deletions docs/internal/releases/v0.6.0-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# v0.6.0 release readiness (#57)

Independent release audit of Stack Manager v0.6.0, performed 2026-10-22 for
[#57](https://github.com/uniskela/stack-manager/issues/57) (parent
[#51](https://github.com/uniskela/stack-manager/issues/51)). Nothing was merged, tagged, released, published
or deployed. No real deployment webhook or production data was used.

## Release summary

| Item | Value |
| --- | --- |
| Audited `main` | `8333dd88d87834890dfcd58e7745e4c8d9994f20` (`feat(deployment): add selective Git change routing and idempotency (#66)`) |
| Target version | 0.6.0 (Release Please PR [#41](https://github.com/uniskela/stack-manager/pull/41), head `d7ced04`) |
| Previous release | v0.5.0 (migrations `0000`–`0003`) |
| New migrations | `0004_sync-follow-up`, `0005_sync-timestamps`, `0006_repository_auto_fetch_policy`, `0007_git_webhook_deliveries`, `0008_git_webhook_body_replay`, `0009_flashy_turbo` (deployment bindings/attempts), `0010_large_salo` (routing). All additive. |
| Features | Scheduled and post-push fetch (#59, #62), security hardening (#61), dashboard (#63), signed GitHub/Gitea/Forgejo push webhooks (#64), per-stack Portainer/generic deployment webhooks and manual Deploy now (#65), selective routing and idempotency (#66). |
| Hardening PR | Branch `uniskela/adhd-v0.6.0-release-verification-and-documentati` ([PR #67](https://github.com/uniskela/stack-manager/pull/67)). |
| Open issues | #51 parent, #57 this gate. #52 and #56 verified on `main` (see below). |

## Verification matrix

"Local" results ran on the audited SHA (or that SHA plus this PR's test/doc changes) on the development VPS.

| Category | Result | Evidence |
| --- | --- | --- |
| Git synchronization | **PASS** | `pnpm check` 656/656 on `main`; scheduler unit tests incl. new worker/server-disabled scan test; per-repository settings Playwright (desktop/mobile) and the server-disabled scenario run separately with `STACK_MANAGER_AUTO_FETCH_ENABLED=false` (passed). Fetch only updates `refs/remotes/origin/*`; drafts (DB) and unpushed commits (`refs/stack-manager/heads/*`) survive failure and upgrade. New audit test: one repository's failing fetch does not block another's routing. |
| Inbound webhooks | **PASS** (automated) | Code review of `providers/git/webhooks.ts`, `git-webhook-service.ts`, `http/route.ts`: raw-byte HMAC-SHA256 with constant-time compare before JSON parse, native headers only, SHA-1/empty/alternate formats rejected, repository/branch identity checked against the stored remote, delivery-ID and body-fingerprint ledger committed in the same transaction as the fetch job, credential/branch/remote fenced inside that transaction. Route-coverage test allowlists the single `signed-webhook` route; all other mutations keep same-origin checks. Existing 3-provider unit and integration suites pass. New test: cross-workspace repository id is rejected. Live forge delivery: **not verified** (operator). |
| Deployment bindings | **PASS** (automated) | Encrypted URL/token, fixed mask, workspace-scoped credential checks, revision CAS, managed-credential triggers, per-stack active-attempt unique index, intent before send, `begin`/`finish` lease fences, interrupted sends → unknown. Outbound: HTTPS only, no embedded credentials, DNS answers all checked, socket pinned to the validated address with original hostname for TLS, no redirects, 5 s connect / 15 s total deadlines, response bodies discarded. Local TLS receiver tests (invalid cert, hostname mismatch, rebinding resolver, redirect, timeout) pass. New tests: credential rotation and disabling reject queued automatic work without sending. |
| Selective routing | **PASS** (automated) | Scenario table independently re-run through real Git/SQLite/worker in `tests/integration/release-audit-routing.test.ts` plus existing suite: stack-only compose change, shared dependency to two stacks, README-only skip, unrelated stack skip, unsafe/rewritten history blocked, duplicate delivery no resend, automation off no deploy, unknown outcome holds until manual Deploy now. Unicode, spaces, `#`, `(`, `)` and a `apps/wiki café` vs `apps/wiki` prefix trap route correctly. |
| Recovery/idempotency | **PASS** with documented risk R1 | Restart before/after fetch and after routing, lease expiry, late-enqueue rollback, signing rotation between accept and route (blocked, cursor kept, next push covers it). Verified fetch exhaustion leaves the push unrouted (fail-closed; see R1). |
| Database migration | **PASS** | Seeded a database with the **actual v0.5.0 code** (admin, workspace, Gitea token credential, repo clone, 2 stacks, draft, unpushed commit, pending job, audit), upgraded with the candidate: 11 migrations, every pre-existing row byte-identical across 9 tables, token decrypts and drives an authenticated fetch, v0.5.0's pending job runs, new defaults correct (auto-fetch on/inherit, webhooks off, no bindings). Reopen is a no-op; upgraded schema equals a fresh schema; `pnpm db:generate` reports no drift. |
| Backup/restore | **PASS** (disposable data) | Online `sqlite3 .backup` (integrity `ok`) + clone archive; stopped-volume `tar` round trip byte-identical with uid 1000 preserved; clean stop leaves no WAL. Pre-upgrade backup restored and run with v0.5.0: 4 migrations, data intact, token decrypts, fetch works. Wrong key: starts, decryption fails, signed webhook rejected. Missing/placeholder key: refuses to start (exit 1). v0.5.0 on an upgraded DB starts but leaves v0.6.0 queued work dormant → documented as unsupported. |
| Security | **PASS** | Gitleaks over `main` history (70 commits): no leaks. Prod `pnpm audit`: no known vulnerabilities. Code-scanning: 20 open Sonar alerts, all previously dispositioned in `docs/internal/security/2026-10-20-sonar-dispositions.md` (manual Sonar status change pending). Secret-scanning alerts: 0. Dependabot alerts are disabled for the repository (R12). Two remaining unresolved Kody threads fixed in this PR. No credential exposure, SSRF, workspace isolation, CSRF or replay regression found. |
| Accessibility | **PASS** | Full Playwright + axe on the audited production build: 43 passed, 13 intentional skips (duplicate-viewport shared mutations; worker/server-disabled scenarios). Server-disabled auto-fetch scenario run separately: passed. Modified webhook spec re-run: passed. New dashboard keyboard check (Tab to **View all activity**, Enter opens Settings → Activity) passed on desktop and mobile. |
| Container and CI | **PASS** (CI) / local image **not run** | [CI on `8333dd8`](https://github.com/uniskela/stack-manager/actions/runs/39720529649): actionlint/shellcheck, check, build, smoke, Playwright, audit, Docker build, healthy start, same-volume restart, missing-key refusal. [Container security on `main`](https://github.com/uniskela/stack-manager/actions/runs/39722448520) (dispatched by this audit, scan-only): passed. [Secret scan](https://github.com/uniskela/stack-manager/actions/runs/39720529616): passed. Locally: actionlint, shellcheck, standalone production smoke, same-data restart, missing-key refusal passed. A local Docker image build was **not run**: the root disk had ~450 MB free and broad Docker cleanup was out of scope. |
| Live operator acceptance | **UNVERIFIED** | Checklist in [Upgrading to v0.6.0](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment). Requires the real forge, reverse proxy, TLS chain and a disposable receiver; not authorized in this audit. |

### Commands run

```text
pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild better-sqlite3 esbuild # ok
pnpm check # main: 656 passed; with this PR: see PR checks
pnpm build # ok (audited SHA, built on tmpfs)
pnpm test:e2e (E2E_GIT_REMOTE=https://github.com/docker/awesome-compose.git E2E_STACK_ROOT=nginx-golang)
# 43 passed, 13 skipped
STACK_MANAGER_AUTO_FETCH_ENABLED=false playwright test repository-auto-fetch.spec.ts # passed
scripts/smoke.sh against .next/standalone/server.js; restart on same data; empty key # ok / ok / exit 1
pnpm audit --prod --audit-level high # no vulnerabilities
pnpm db:generate # no schema changes
actionlint 1.7.12, shellcheck scripts/*.sh scripts/ci/*.sh # clean
gitleaks 8.30.1 git (origin/main) # no leaks
v0.5.0 → candidate upgrade, backup, restore and wrong/missing-key checks (scratch harness) # passed
```

The upgrade/restore harness ran from disposable scratch copies of `v0.5.0` and `8333dd8`; it is not part of the
repository because it needs the old release tree. Its assertions are summarised in the matrix above.

## Findings and dispositions

| # | Finding | Risk | Blocker? | Disposition |
| --- | --- | --- | --- | --- |
| F1 | All 13 deployment audit actions had no human label; Dashboard and Settings → Activity showed raw ids such as `deployment.unknown`. | Operators misread the audit trail during an incident. | No | **Fixed**: labels that state HTTP acceptance, not health; typed completeness test fails if a future action lacks a label. |
| F2 | Kody [r4325951812](https://github.com/uniskela/stack-manager/pull/63#discussion_r4325951812): repository-scope test could pass with an out-of-scope row. | Weak regression test. | No | **Fixed**: exact set asserted, r3 explicitly excluded. |
| F3 | Kody [r4332700230](https://github.com/uniskela/stack-manager/pull/64#discussion_r4332700230): e2e cleanup assertion in `finally` can mask the original failure. | Misleading CI diagnosis. | No | **Fixed**: cleanup is asserted only after a passing body and never throws over an earlier failure. |
| F4 | Verified push whose fetch exhausts retries is never routed (R1). | Missed automatic deployment; fail-closed. | No | **Documented** in Deployment/Git webhook troubleshooting; regression test pins the safe behaviour. Follow-up recommended. |
| F5 | Backup guide described backups as "planned" and lacked WAL, permissions, key and rollback guidance; release doc said ingress never deploys. | Unsafe upgrade/rollback practice. | Yes (docs) | **Fixed**: tested procedures, failure-mode table, rollback and replay-ledger guidance, v0.6.0 release gate, upgrade page and operator checklist. |
| F6 | Routing patterns reject `{ } [ ] ( ) ! :` even as literals; undocumented. | Confusing configuration. | No | **Documented** with a verified `?` workaround. |
| F7 | Changelog in #41 repeats "explain automatic fetch and manual fallback" four times and lists #59's six intermediate `feat(sync)` commits separately. | Cosmetic, noisy release notes. | No | **Recommendation** only (Release Please owns the file): consolidate in the GitHub Release text after publication, and add the upgrade/backup note. |
| F8 | Roadmap (`MVP_PLAN.md`) and `AGENTS.md` still called deployment routing the next active phase. | Misleading project status for contributors and agents. | No | **Fixed**: both say it is implemented on `main` for v0.6.0 and in its release gate. |

Kody's PR #66 items were re-checked against the code: the repository-lookup failure still propagates for job
retry without advancing shared checkpoints (correct: skipping would move cursors past unrouted sources), shared
reads are hoisted while per-stack guarded enqueue stays inside one immediate transaction (no unsafe batching),
and the SQLite smoke helper closes its handle on failure paths. Those dispositions stand.

## Remaining risks

| ID | Risk | Severity | Likelihood | Impact | Mitigation |
| --- | --- | --- | --- | --- | --- |
| R1 | A verified push is not routed if its fetch fails all 5 retries (~75 s of backoff, e.g. forge restarting). A later polled fetch does not route it. | Medium | Low–medium | Missed automatic deployment; no unsafe deploy. | Next verified push includes the changes (baseline unchanged); Deploy now; documented. Follow-up [#68](https://github.com/uniskela/stack-manager/issues/68). |
| R2 | Exactly-once delivery is impossible across HTTP; Portainer ignores `Idempotency-Key`. | Medium | Low | Possible duplicate redeploy after a crash/timeout. | Intent before send, unknown outcome, persistent automation hold, operator inspection. |
| R3 | Live forge versions, reverse proxies and receivers may differ from fixtures (byte rewriting, redirects, TLS chains). | Medium | Medium | 401/415 ingress or unknown/rejected sends. | Operator checklist steps 2–3, 5–12 before production opt-in. |
| R4 | Git remote host check happens before `git` resolves DNS (rebinding TOCTOU), already listed in SECURITY.md. | Medium | Low | SSRF to a private address via a hostile DNS name. | HTTPS only, no redirects, private networks denied by default. Deployment webhooks are pinned; Git is not. |
| R5 | In-memory ingress rate limit resets on restart and is skipped without a trustworthy client IP. | Low | Medium | Weak flood protection. | Proxy-level limits (documented). |
| R6 | Restoring an older backup restores an older replay/routing ledger. | Medium | Low | Redeliveries can fetch/route again; sent requests are not undone. | Documented: keep automation off until receivers are inspected. |
| R7 | Delivery, decision and attempt history grows until repository/stack removal. | Low | Medium | Database growth on busy repositories. | Monitor size; retention is future work. |
| R8 | 20 Sonar alerts await manual status changes; 25 reviewed advisories remain from #66. | Low | n/a | Noise in security dashboards. | Dispositions recorded; apply in Sonar. |
| R9 | Dependabot alerts disabled. | Low | Low | Slower notice of new advisories. | `pnpm audit` and Trivy run in CI; consider enabling alerts. |
| R10 | Local Docker build not exercised on the audit host. | Low | Low | — | CI Docker job and container-security pass on `main`. |
| R11 | After R1 occurs, every later successful fetch queues a no-op routing job for that repository. | Low | Low | Negligible worker load. | Covered by the R1 follow-up. |

## Go/No-Go

**CONDITIONAL GO.** The code on `8333dd8` plus this hardening PR is ready: all automated gates pass, no
critical or high defect was found, and the remaining risks fail closed or are documented. Before merging
release PR #41 (and in any case before enabling automatic deployment in production), the operator must:

1. Merge this hardening PR after review so its fixes and docs ship in v0.6.0, then let Release Please update #41.
2. Approve the pending workflow runs on #41 (they show `action_required`) and confirm they pass on its head.
3. Complete the [acceptance checklist](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment)
on a disposable stack, recording results on #57. Steps 2, 3, 6, 9–12 and 14 are the minimum.

If any checklist step fails, treat it as a release blocker and record it on #57.
7 changes: 7 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@
"group": "Deploy & operate",
"kind": "doc"
},
{
"source": "docs/public/UPGRADING_0_6.md",
"slug": "upgrading-0-6",
"title": "Upgrading to v0.6.0",
"group": "Deploy & operate",
"kind": "doc"
},
{
"source": "docs/public/AUTH_AND_CREDENTIALS.md",
"slug": "auth-and-credentials",
Expand Down
Loading
Loading