Skip to content

Commit defd434

Browse files
committed
fix: keep subscriptions and webhooks consistent
1 parent 49d5ea1 commit defd434

10 files changed

Lines changed: 229 additions & 51 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@ GitHub Releases.
88

99
## Unreleased
1010

11+
## v1.1.1 - 2026-08-09
12+
13+
Patch release for subscription webhook consistency and safer OAuth failure
14+
handling.
15+
16+
### Fixed
17+
18+
- Subscription create, pause, edit, and delete operations now compensate the
19+
database mutation when GitHub webhook synchronization fails, then attempt to
20+
restore the previous repository hook configuration.
21+
- Repository webhook event unions and synchronization locks now use the stable
22+
GitHub repository id, so repository renames cannot split hook state.
23+
24+
### Security
25+
26+
- OAuth callback failures are logged server-side and no longer expose internal
27+
provider or transport error details in the browser response.
28+
29+
### Operations
30+
31+
- Production deployment documentation now distinguishes the local Compose
32+
stack from the shared `core-postgres` WS04 deployment.
33+
1134
## v1.1.0 - 2026-07-18
1235

1336
Stable Telegram Bot API 10.2 release. Promoted from `v1.1.0-alpha.3` after a

‎README.md‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<p align="center">
66
<a href="https://github.com/FreshLabDev/branchy/releases"><img src="https://img.shields.io/github/v/release/FreshLabDev/branchy?include_prereleases&sort=semver&style=for-the-badge&label=latest&labelColor=0f172a&color=4c8c4a" alt="latest version"></a>
7-
<a href="docs/versioning.md"><img src="https://img.shields.io/badge/stable-v0.2.0-4c8c4a?style=for-the-badge&labelColor=0f172a" alt="stable version"></a>
7+
<a href="docs/versioning.md"><img src="https://img.shields.io/badge/stable-v1.1.1-4c8c4a?style=for-the-badge&labelColor=0f172a" alt="stable version"></a>
88
<a href="go.mod"><img src="https://img.shields.io/github/go-mod/go-version/FreshLabDev/branchy?style=for-the-badge&logo=go&logoColor=white&label=go&labelColor=0f172a&color=00ADD8" alt="go version"></a>
99
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-334155?style=for-the-badge&labelColor=0f172a" alt="license"></a>
1010
<a href="https://t.me/branchy_bot"><img src="https://img.shields.io/badge/telegram-%40branchy__bot-26A5E4?style=for-the-badge&logo=telegram&logoColor=white&labelColor=0f172a" alt="telegram bot"></a>
@@ -46,12 +46,13 @@ Branchy keeps the MVP deliberately narrow:
4646

4747
| Channel | Version | Meaning |
4848
|:--|:--|:--|
49-
| Latest | `v1.1.0` | Stable Telegram Rich Messages release |
50-
| Previous | `v1.0.3` | Previous stable release using classic HTML delivery |
49+
| Latest | `v1.1.1` | Stable Telegram Rich Messages release with webhook consistency fixes |
50+
| Previous | `v1.1.0` | Stable Telegram Rich Messages release |
5151

52-
The MVP has been live-tested with Telegram and GitHub. `v1.1.0` is the current
53-
stable release and promotes the richer Telegram delivery path after a clean
54-
production soak and adversarial media/fallback testing.
52+
The MVP has been live-tested with Telegram and GitHub. `v1.1.1` is the current
53+
stable release. It keeps the richer Telegram delivery path and hardens
54+
subscription changes so a failed GitHub webhook sync does not leave a user
55+
operation half-applied.
5556

5657
---
5758

@@ -119,10 +120,12 @@ Branchy runs startup migrations from `migrations/` and records completed
119120
versions in `schema_migrations`. Keep `AUTO_MIGRATE=true` for local
120121
development.
121122

122-
The bundled `docker compose` runs a local PostgreSQL and seeds a minimal shared
123-
`core` schema (`deploy/core-init.sql`) so migrations that reference
124-
`core.person` / `core.chat` boot cleanly. In the shared production deployment
125-
Branchy instead connects to the existing `core-postgres`.
123+
The bundled `docker compose` is for local development only. It runs a local
124+
PostgreSQL and seeds a minimal shared `core` schema (`deploy/core-init.sql`) so
125+
migrations that reference `core.person` / `core.chat` boot cleanly. In the
126+
shared production deployment Branchy instead connects to the existing
127+
`core-postgres`; use the production deployment procedure in
128+
[`docs/releases.md`](docs/releases.md).
126129

127130
---
128131

@@ -179,7 +182,9 @@ poll pending jobs with FOR UPDATE SKIP LOCKED
179182
```
180183

181184
Temporary Telegram or GitHub failures retry with `retry_at` and `attempts`.
182-
Permanent delivery failures are marked `failed`.
185+
Permanent delivery failures are marked `failed`. Subscription changes that
186+
cannot synchronize the repository webhook are compensated back to their prior
187+
database state, followed by a best-effort restoration of the prior hook.
183188

184189
---
185190

‎docker-compose.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# SPDX-License-Identifier: Apache-2.0
2+
# Local development only. Production uses /opt/stacks/branchy and shared core-postgres.
23
services:
34
postgres:
45
image: postgres:17-alpine

‎docs/architecture.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ handlers do not send Telegram messages directly; they create durable
6767
- GitHub webhooks are owned per repository, not per subscription. Active
6868
subscription events are unioned into the repository hook configuration. When
6969
the active event union becomes empty, Branchy deletes its matching hook.
70+
The union and in-process lock are keyed by the stable GitHub repository id,
71+
so repository renames do not split synchronization state. If a subscription
72+
mutation cannot synchronize GitHub, Branchy compensates the database change
73+
and best-effort restores the previous hook configuration before returning the
74+
error to Telegram.
7075
- GitHub webhook requests validate signature, dedupe delivery IDs, create
7176
durable notification jobs, and return `200` without waiting for Telegram.
7277
- The notification worker retries Telegram `429` and temporary failures using

‎docs/releases.md‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Use this shape for release notes (same sections as `CHANGELOG.md`):
5656
- Migration notes, env, or deploy notes when relevant.
5757
```
5858

59-
GitHub Release **title** is the version only (`v1.1.0-alpha.1`), not
59+
GitHub Release **title** is the version only (`v1.1.1`), not
6060
`Branchy v…`. Copy the matching `CHANGELOG.md` version section into the release
6161
body (skip the `## vX.Y.Z` heading).
6262

@@ -80,13 +80,34 @@ gh release create v0.1.0-alpha.1 \
8080
Create a stable release:
8181

8282
```sh
83-
git tag -a v0.1.0 -m "v0.1.0"
83+
git tag -a v1.1.1 -m "v1.1.1"
8484
git push origin main
85-
git push origin v0.1.0
86-
gh release create v0.1.0 \
87-
--title "v0.1.0" \
85+
git push origin v1.1.1
86+
gh release create v1.1.1 \
87+
--title "v1.1.1" \
8888
--notes-file /tmp/branchy-release-notes.md
8989
```
9090

9191
Do not publish a release before the release notes, tag, and verification status
9292
all match.
93+
94+
## Production Deployment
95+
96+
Production runs the `branchy` service from `/opt/stacks/branchy` and uses the
97+
shared `core-postgres`. The root `docker-compose.yml` is local-development
98+
configuration and must not be used as a production deployment source.
99+
100+
After the commit and tag are published, deploy only the Branchy stack with the
101+
WS04 deployment workflow. The deploy command snapshots the current compose,
102+
environment, and image ids, waits for health, and rolls back automatically on a
103+
failed health check:
104+
105+
```sh
106+
WS04_HOST=ssh.amdumo.fun ws04 deploy branchy --yes --health-timeout 120
107+
WS04_HOST=ssh.amdumo.fun ws04 stack status branchy
108+
WS04_HOST=ssh.amdumo.fun ws04 audit --stack branchy --since 1h
109+
```
110+
111+
The final check must confirm the released version in `/healthz`, a healthy
112+
container with no restart, fresh Telegram polling and outbox worker timestamps,
113+
and zero pending, processing, or failed notification jobs.

‎docs/versioning.md‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Versioning
22

3-
Branchy uses SemVer-style versions with pre-release tags while the bot is still
4-
before `v1.0.0`.
3+
Branchy uses SemVer-style versions. Stable releases use the `v1.x` line; alpha,
4+
beta, and rc tags are reserved for changes that still need limited live
5+
validation.
56

67
## Version Line
78

@@ -18,32 +19,36 @@ v0.1.0 public MVP release
1819
Branchy skipped `beta` and `rc` for this first line because the core Telegram
1920
and GitHub flows were already live-tested during the alpha cycle.
2021

21-
After the public MVP release:
22+
Current stable line:
2223

2324
```text
2425
v0.1.1 bug fixes without new behavior
2526
v0.2.0 notable UX, operations, or MVP-compatible feature improvements
26-
v1.0.0 stable production contract after real production usage
27+
v1.0.0 first stable production contract
28+
v1.1.0 Telegram Rich Messages and Bot API 10.2 delivery
29+
v1.1.1 webhook consistency and OAuth error-surface fixes
30+
```
31+
32+
Future releases:
33+
34+
```text
35+
v1.1.x backward-compatible fixes and operational hardening
36+
v1.2.0 notable MVP-compatible product or operations improvements
37+
v2.0.0 intentionally breaking contract changes
2738
```
2839

2940
## Rules
3041

31-
- Use `alpha` while core flows are not proven with real Telegram and GitHub
32-
credentials.
33-
- Use `beta` after the bot works end to end, but only for limited users.
34-
- Use `rc` when the release is intended to become public and only fixes are
35-
expected.
42+
- Use `alpha`, `beta`, or `rc` only while the corresponding live validation is
43+
still limited or incomplete.
3644
- Use patch versions for fixes that do not change product behavior or runtime
3745
assumptions.
3846
- Use minor versions for visible UX improvements, operational improvements, or
3947
supported event/delivery changes that remain in MVP scope.
40-
- Do not use `v1.0.0` until the bot has real production history, stable
41-
deployment practices, and a clear behavior contract.
4248

43-
## Breaking Changes Before v1.0.0
49+
## Breaking-Sensitive Areas
4450

45-
Before `v1.0.0`, Branchy can still change faster than a mature product, but
46-
breaking changes must be explicit when they affect:
51+
Breaking changes must be explicit when they affect:
4752

4853
- required environment variables
4954
- PostgreSQL schema or migration requirements

‎internal/db/db.go‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,40 @@ func (s *Store) CreateSubscription(ctx context.Context, sub Subscription) (strin
237237
return id, inserted, err
238238
}
239239

240+
// FindSubscriptionByConfig returns the existing row that CreateSubscription
241+
// would reactivate. Callers use it to restore the prior status if webhook
242+
// synchronization fails after the upsert.
243+
func (s *Store) FindSubscriptionByConfig(ctx context.Context, sub Subscription) (Subscription, error) {
244+
var existing Subscription
245+
err := s.pool.QueryRow(ctx, `
246+
SELECT s.id::text, s.telegram_user_id, s.destination_type, s.destination_chat_id,
247+
s.github_repo_id, s.repo_full_name, s.events, s.branch_mode, s.branch_name,
248+
s.branch_names, s.pull_request_actions, s.release_mode,
249+
s.status, s.pause_reason, r.default_branch, r.html_url
250+
FROM subscriptions s
251+
JOIN repositories r ON r.github_repo_id = s.github_repo_id
252+
WHERE s.telegram_user_id = $1
253+
AND s.destination_type = $2
254+
AND s.destination_chat_id = $3
255+
AND s.github_repo_id = $4
256+
AND s.events = $5
257+
AND s.branch_mode = $6
258+
AND s.branch_names = $7
259+
AND s.pull_request_actions = $8
260+
AND s.release_mode = $9
261+
`, sub.TelegramUserID, sub.DestinationType, sub.DestinationChatID, sub.GitHubRepoID,
262+
sub.Events, sub.BranchMode, sub.BranchNames, sub.PullRequestActions, sub.ReleaseMode).Scan(
263+
&existing.ID, &existing.TelegramUserID, &existing.DestinationType, &existing.DestinationChatID,
264+
&existing.GitHubRepoID, &existing.RepoFullName, &existing.Events, &existing.BranchMode,
265+
&existing.BranchName, &existing.BranchNames, &existing.PullRequestActions,
266+
&existing.ReleaseMode, &existing.Status, &existing.PauseReason, &existing.DefaultBranch, &existing.HTMLURL,
267+
)
268+
if errors.Is(err, pgx.ErrNoRows) {
269+
return Subscription{}, ErrNotFound
270+
}
271+
return existing, err
272+
}
273+
240274
func (s *Store) ListSubscriptionsByUser(ctx context.Context, telegramUserID int64) ([]Subscription, error) {
241275
rows, err := s.pool.Query(ctx, `
242276
SELECT s.id::text, s.telegram_user_id, s.destination_type, s.destination_chat_id,
@@ -312,13 +346,13 @@ func (s *Store) ReconcileSubscriptionRepoName(ctx context.Context, repoID int64,
312346
return err
313347
}
314348

315-
func (s *Store) ListActiveEventsForRepo(ctx context.Context, repoFullName string) ([]string, error) {
349+
func (s *Store) ListActiveEventsForRepo(ctx context.Context, repoID int64) ([]string, error) {
316350
rows, err := s.pool.Query(ctx, `
317351
SELECT DISTINCT unnest(events)
318352
FROM subscriptions
319-
WHERE repo_full_name = $1 AND status = 'active'
353+
WHERE github_repo_id = $1 AND status = 'active'
320354
ORDER BY 1
321-
`, repoFullName)
355+
`, repoID)
322356
if err != nil {
323357
return nil, err
324358
}
@@ -347,6 +381,18 @@ func (s *Store) UpdateSubscriptionStatus(ctx context.Context, telegramUserID int
347381
return requireAffected(tag, err)
348382
}
349383

384+
// RestoreSubscriptionStatus is used by the subscription service's
385+
// compensating rollback. Unlike a user-driven status update it preserves the
386+
// automatic pause reason exactly as it was before the failed webhook sync.
387+
func (s *Store) RestoreSubscriptionStatus(ctx context.Context, telegramUserID int64, id, status, pauseReason string) error {
388+
tag, err := s.pool.Exec(ctx, `
389+
UPDATE subscriptions
390+
SET status = $3, pause_reason = $4, updated_at = now()
391+
WHERE telegram_user_id = $1 AND id = $2
392+
`, telegramUserID, id, status, pauseReason)
393+
return requireAffected(tag, err)
394+
}
395+
350396
func (s *Store) UpdateSubscriptionEventsAndSettings(ctx context.Context, telegramUserID int64, id string, events []string, branchMode string, branchNames []string, pullRequestActions []string, releaseMode string) error {
351397
tag, err := s.pool.Exec(ctx, `
352398
UPDATE subscriptions
@@ -406,6 +452,22 @@ func (s *Store) DeleteSubscription(ctx context.Context, telegramUserID int64, id
406452
return requireAffected(tag, err)
407453
}
408454

455+
// RestoreSubscription reinstates a deleted row with its original id and
456+
// settings when the corresponding GitHub webhook mutation cannot be completed.
457+
func (s *Store) RestoreSubscription(ctx context.Context, sub Subscription) error {
458+
_, err := s.pool.Exec(ctx, `
459+
INSERT INTO subscriptions (
460+
id, telegram_user_id, destination_type, destination_chat_id,
461+
github_repo_id, repo_full_name, events, branch_mode, branch_name,
462+
branch_names, pull_request_actions, release_mode, status, pause_reason
463+
)
464+
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
465+
`, sub.ID, sub.TelegramUserID, sub.DestinationType, sub.DestinationChatID,
466+
sub.GitHubRepoID, sub.RepoFullName, sub.Events, sub.BranchMode, sub.BranchName,
467+
sub.BranchNames, sub.PullRequestActions, sub.ReleaseMode, sub.Status, sub.PauseReason)
468+
return err
469+
}
470+
409471
func (s *Store) UpsertRepoHook(ctx context.Context, repoID int64, fullName string, hookID int64, events []string, payloadURL string) error {
410472
_, err := s.pool.Exec(ctx, `
411473
INSERT INTO repo_hooks (github_repo_id, full_name, hook_id, events, payload_url)

‎internal/oauth/service.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ func (s *Service) CreateAuthURL(ctx context.Context, telegramUserID int64) (stri
8686

8787
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8888
if err := s.handleCallback(r.Context(), r); err != nil {
89+
slog.Warn("github oauth callback failed", "error", err)
8990
w.Header().Set("Content-Type", "text/html; charset=utf-8")
9091
w.WriteHeader(http.StatusBadRequest)
91-
_, _ = fmt.Fprintf(w, "<!doctype html><title>Branchy</title><p>GitHub connection failed: %s</p>", html.EscapeString(err.Error()))
92+
_, _ = fmt.Fprint(w, "<!doctype html><title>Branchy</title><p>GitHub connection failed. Return to Telegram and try again.</p>")
9293
return
9394
}
9495
w.Header().Set("Content-Type", "text/html; charset=utf-8")

0 commit comments

Comments
 (0)