Skip to content
Merged
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
25 changes: 23 additions & 2 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ on:
description: 'Exact 40-character commit SHA admitted for this release'
required: true
type: string
release_source_branch:
description: 'Governed source branch for a merged immutable release snapshot'
required: false
type: string
release_pull_request:
description: 'Merged pull request that reviewed the immutable snapshot'
required: false
type: string
release_notes:
description: 'Release notes (markdown)'
required: true
Expand Down Expand Up @@ -85,6 +93,10 @@ permissions:
jobs:
# Combined version extraction and validation (saves a checkout)
prepare:
permissions:
actions: read
contents: read
pull-requests: read
# Stable releases use hosted runners regardless of their Windows-signing
# decision. Prereleases retain the credential-free PVE acceleration path.
runs-on: ${{ !contains(inputs.version, '-') && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","pulse-pve-compile"]') }}
Expand Down Expand Up @@ -134,11 +146,20 @@ jobs:
persist-credentials: false
fetch-depth: 0

- name: Verify reviewed release snapshot
id: snapshot
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SOURCE_BRANCH: ${{ inputs.release_source_branch }}
RELEASE_PULL_REQUEST: ${{ inputs.release_pull_request }}
run: python3 scripts/release_control/release_snapshot.py

- name: Extract version
id: extract
env:
VERSION_INPUT: ${{ inputs.version }}
HISTORICAL_ASSET_BACKFILL_INPUT: ${{ inputs.historical_asset_backfill_only }}
SNAPSHOT_SOURCE_BRANCH: ${{ steps.snapshot.outputs.source_branch }}
run: |
set -euo pipefail
if [[ ! "${VERSION_INPUT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
Expand All @@ -164,12 +185,12 @@ jobs:
exit 1
fi

SOURCE_BRANCH="${GITHUB_REF_NAME}"
SOURCE_BRANCH="${SNAPSHOT_SOURCE_BRANCH}"
HISTORICAL_ASSET_BACKFILL_ONLY="${HISTORICAL_ASSET_BACKFILL_INPUT}"
python3 scripts/write_github_output.py tag "${TAG}"
python3 scripts/write_github_output.py version "${VERSION}"
echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT
echo "source_branch=${SOURCE_BRANCH}" >> $GITHUB_OUTPUT
python3 scripts/write_github_output.py source_branch "${SOURCE_BRANCH}"
python3 scripts/write_github_output.py historical_asset_backfill_only "${HISTORICAL_ASSET_BACKFILL_ONLY}"
echo "Version: ${VERSION}, Tag: ${TAG}, Prerelease: ${IS_PRERELEASE}, Branch: ${SOURCE_BRANCH}, HistoricalBackfillOnly: ${HISTORICAL_ASSET_BACKFILL_ONLY}"

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ scripts/release_control/*
!scripts/release_control/contract_audit_test.py
!scripts/release_control/customer_promotion_lease.sh
!scripts/release_control/control_plane.py
!scripts/release_control/release_snapshot.py
!scripts/release_control/release_snapshot_test.py
!scripts/release_control/generate_platform_support_frontend_module.py
!scripts/release_control/control_plane_audit.py
!scripts/release_control/control_plane_audit_test.py
Expand Down
23 changes: 23 additions & 0 deletions docs/qualification/release-v6.4-discord-redaction/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Discord diagnostic credential backport

Security backport of Core commit 3c77ecb338ee3d68f1dd7d57a367b4727040a61c,
limited to the notification redactor and its regression tests. Eligible under
Release Train rules 2/4: synthetic tests on release-line base
acb841d3d667545a493033fad48a810db84586b5 exposed Discord webhook path tokens.
Discord documents secure webhook tokens and token-authorised operations:
https://docs.discord.com/developers/resources/webhook (read 7 September 2026).

The change masks the suffix after /webhooks/ on exact Discord hosts, including
legacy/versioned paths and escaped credentials. It modifies diagnostics, not
request destinations, and preserves transport error causes.

Validation: importing tests alone reproduced failures in helper output and
transport diagnostics. With the repair, focused race tests repeated 20 times
passed, covering helper cases, transport errors and actual rate-limit logs.
Logs are retained in the lane packet 20260907T170524Z-release-line.
No full suite, release qualification or installed delivery test was performed.

This does not scrub historical delivery text or establish customer exposure.
Telegram parsing review remains separate. Protected PR checks, steward risk
assessment and exact-candidate qualification remain required; prior adverse
latency and excluded crash evidence are not cleared by these focused tests.
32 changes: 32 additions & 0 deletions docs/qualification/release-v6.4-query-ntfy-redaction/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Encoded-query and resolved-ntfy diagnostic security backport

Release Train rules 2/4: named security defects reproduced on supplied candidate
71c721ccee8bea8a64a130fdbc0ea78292ff748b, not new product scope.
Source: Core 21ed7a89270a5ee1850254d0289c013567871fa8.
Only that correction is backported; mixed main is not imported. The HTTP test
context was absent here, so retain the complete diagnostic-context test from
that source when resolving the conflict. Production HTTP code is unchanged.

Before repair, TestDeliveryEncodedQueryConfidentiality and
TestDeliveryResolvedNtfyConfidentiality fail using synthetic credentials and
in-memory transports. After repair, the focused notification/redaction tests
and TestGetDeliveryLog HTTP tests pass. Logs are retained in
/var/lib/pulse-maintainer/queue/staging/20260907T181017Z-release-line/
(before.log, after.log, api.log; race.log records the separate repeated matrix).

The repair decodes supported query names once and masks repeated occurrences;
resolved ntfy projects transport errors before logging and returning. The
matrix checks unchanged destination, payload, event identity, safe userinfo
rejection and error-cause unwrapping. This is bounded diagnostic protection,
not arbitrary-secret detection, evidence of customer exposure, recipient
receipt, full-suite qualification or release approval.

Fresh external acceptance context: OWASP Logging Cheat Sheet, Data to exclude
and Verification, inspected 2026-09-07:
https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
Credentials should not be logged; diagnostic failure paths need verification.

Delivery still owns protected-PR integration and exact-candidate qualification.
The retained 940f788d latency failure is not cleared by these focused tests;
Benchmarks remains advisory for source landing. Stable needs exact RC soak
and founder packet approval. No excluded crash investigation was performed.
25 changes: 25 additions & 0 deletions docs/qualification/release-v6.4-telegram-redaction/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Telegram diagnostic credential backport — 7 September 2026

Release Train rules 2/4 permit this named security repair on base
`dcf7e499613679c941abe49800f1294b5714fde1`. Backports only code, tests and
notification contract from reviewed Core `192a72e05c06b2a2ff3a04bb2ef53ded78950e47`.
Existing Discord masking and diagnostic context handling are preserved.

Fresh independent primary evidence: https://core.telegram.org/bots/api#making-requests
identifies bot authentication tokens in request paths and supports local API
servers. This is diagnostic containment, not a new product surface.

Tests imported alone failed (before.log, exit 1):
`go test ./internal/notifications -run '^Test(RedactWebhookURLSecrets|TelegramWebhookDiagnosticsRedactPath)$' -count=1`.
Synthetic escaped path credentials survived helper and transport diagnostics;
non-credential bot hostnames/query URLs also lost diagnostic context.

Repair validation (after.log):
`go test -race ./internal/notifications -run '^Test(RedactWebhook|WebhookRateLimitLogsRedactURLSecrets|SlackWebhookDiagnosticsRedactPath|DiscordWebhookDiagnosticsRedactPath|TelegramWebhookDiagnosticsRedactPath)' -count=20`.

No full qualification, installed change, customer exposure assertion, historical
stored-data cleanup or recipient receipt proof. Protected integration and all
eight enforced checks remain required. Existing adverse latency and excluded
crash evidence are not cleared. Delivery owns release maturity and publication.

Result: exit 0, all selected tests passed with race detection (20 repeats).
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ok github.com/rcourtman/pulse-go-rewrite/internal/notifications 1.142s
18 changes: 18 additions & 0 deletions docs/qualification/release-v6.4-telegram-redaction/before.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
--- FAIL: TestRedactWebhookURLSecrets (0.00s)
--- FAIL: TestRedactWebhookURLSecrets/telegram_escaped_prefix (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://api.telegram.org/%62ot123:telegram-secret/sendMessage", want "https://api.telegram.org/botREDACTED/sendMessage"
--- FAIL: TestRedactWebhookURLSecrets/telegram_local_escaped_prefix (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "http://localhost:8081/%62ot123:telegram-secret/sendMessage", want "http://localhost:8081/botREDACTED/sendMessage"
--- FAIL: TestRedactWebhookURLSecrets/telegram_no_method_with_URL_query (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://api.telegram.org/botREDACTED//example.org/status", want "https://api.telegram.org/botREDACTED?next=https://example.org/status"
--- FAIL: TestRedactWebhookURLSecrets/bot_query_is_not_a_path (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://example.org/hook?next=https://botREDACTED/status", want "https://example.org/hook?next=https://bot.example.org/status"
--- FAIL: TestRedactWebhookURLSecrets/telegram_fragment (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://api.telegram.org/botREDACTED", want "https://api.telegram.org/botREDACTED#diagnostic"
--- FAIL: TestRedactWebhookURLSecrets/bot_hostname_is_not_a_path (0.00s)
webhook_url_redaction_test.go:76: RedactWebhookURLSecrets() = "https://botREDACTED/hook?channel=ops", want "https://bot.example.org/hook?channel=ops"
--- FAIL: TestTelegramWebhookDiagnosticsRedactPath (0.00s)
webhook_url_redaction_test.go:228: unsafe transport diagnostic: Post "https://api.telegram.org/%62ot123:telegram-secret/sendMessage": connection refused
FAIL
FAIL github.com/rcourtman/pulse-go-rewrite/internal/notifications 0.005s
FAIL
15 changes: 13 additions & 2 deletions docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,19 @@ without the other lanes changing the candidate underneath it.
every third train and records the decision here.
2. Each train has its own branch, `release/v6.N`, created from `main` at cut
time and declared in `docs/release-control/control_plane.json` so the
release workflow refuses a dispatch from any other branch. `main` is never
frozen. A fix for something found in the candidate is backported to the
release workflow verifies that governed source line. `main` is never
frozen. A selected release is an immutable commit, not the current tip of
the train. Its preparation PR uses a fixed `release-candidate/<packet>`
ref and passes the normal protected review path into the governed line.
Qualification, builds and publication use that PR's exact head commit,
even when the merge or later train commits contain newer work. Snapshot
dispatch must verify the merged PR's head, canonical repository, source
line and continued ancestry in published history. The workflow and source
SHA must both equal the admitted snapshot. Later commits belong to another
release unless the maintainer explicitly rejects the selected candidate
for a concrete defect in that candidate. Merely finding newer work does
not invalidate qualification or restart a release.
A fix for something found in the candidate is backported to the
release branch through a pull request; each backport produces the next
`rc.N` and restarts the soak. After general availability the branch is
the patch line for that train.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@

## Purpose

### Immutable release source

Continuous development must not change an admitted release's source. The
preparation PR's qualified head stays fixed on `release-candidate/<packet>`
while its governed source branch continues receiving work. Dispatch verifies
that the canonical PR merged into the version's governed source line, that its
head and ref match the admitted snapshot, and that its merge remains in that
line's published history. Qualification, workflow execution, compiler dispatch
and published artifacts bind to that head, not a later merge or branch tip.
The source workflow must implement the snapshot input and provenance contract
before the maintainer spends an exact qualification run on it. Later changes
belong to the next candidate unless the maintainer explicitly rejects the
selected source for a concrete defect. Existing maturity, soak, failed-check
and publication-authority boundaries still apply.
`scripts/release_control/release_snapshot.py` owns snapshot identity validation.
Its executable identity cases are in `release_snapshot_test.py`, and the staged
workflow contract is verified in `release_promotion_policy_test.py`.


### Benchmark qualification evidence

The Build and Test benchmark job retains `bench-metadata.txt` together with
Expand Down
17 changes: 17 additions & 0 deletions docs/release-control/v6/internal/subsystems/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@

## Purpose

**Availability backfill preserves concurrent discovery changes (7 September 2026)**

The backfill List snapshot is a work list, not an authoritative record to save.
Read the current discovery, derive a missing suggestion from its current identity,
and persist under one store write lock. State-provider reads stay outside that
lock. Preserve newer identity, URL, engine version, user notes and existing
(including dismissed) proposals; never resurrect a discovery deleted after List.
A failed persistence attempt remains an error without installing the proposed
change in cache. `TestService_BackfillPreservesConcurrentManualRepair` in `service_test.go`
deterministically pauses SetReadState
backfill after List, completes manual ESPHome repair, then resumes backfill and
checks both cache and encrypted restart.
`TestStore_BackfillAvailabilitySuggestionUsesCurrentRecord` in `store_test.go`
covers current-identity
inference, dismissal, deletion, unsupported identity and persistence failure.
This is discovery state-integrity proof, not incident or notification acceptance.

### Host-local network evidence exclusion

Automatic PVE association must not treat loopback, unspecified, multicast or
Expand Down
54 changes: 54 additions & 0 deletions docs/release-control/v6/internal/subsystems/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,3 +607,57 @@ Queue-free regression tests cover both hosts, encoded and legacy paths,
lookalike/unrelated hosts, transport errors and actual rate-limit log output.
This does not establish customer exposure, recipient receipt or recognition of
arbitrary custom webhook secrets.

### Discord webhook diagnostic path confidentiality

On exact `discord.com` and legacy `discordapp.com` hosts, the shared redactor
masks the suffix after `/webhooks/`, including the webhook ID and token.
Versioned API prefixes remain visible; encoded paths, host casing and ports
cannot bypass masking. Unrelated paths and lookalike hosts are unchanged.
Configured destinations and transport error causes are not modified.

[Discord's webhook reference](https://docs.discord.com/developers/resources/webhook)
identifies the secure webhook token and token-authorised operations. Focused
synthetic regressions cover helper output, transport diagnostics and actual
rate-limit logs. This is not evidence of customer exposure, recipient receipt,
release qualification, or protection of arbitrary custom-host credentials.

### Telegram diagnostic path parsing

Telegram bot-path masking operates on the parsed, decoded URL path and clears
RawPath after replacement. This covers percent-encoded bot prefixes without
mistaking a hostname or a URL inside a query for a bot path. Method suffixes,
query diagnostics and fragments remain intact; configured destinations and
transport-error causes are unchanged. Host-independent masking is retained for
local API servers, which are supported by the
[Telegram API documentation](https://core.telegram.org/bots/api#making-requests).

Focused regression tests cover escaped prefixes/tokens, local servers, missing
method suffixes, query URLs, fragments, transport errors and rate-limit logs.
This is diagnostic containment, not evidence of customer exposure or recipient
delivery. Arbitrary path secrets and unrecognised query credentials remain
outside this bounded change.

### Bounded diagnostic confidentiality: query representations and bypass callers

Recognised query names are exactly token, apikey, api_key, key, secret and
password after one URL query decode. Every repeated occurrence is masked,
including mixed literal/escaped names. Unrelated names, ordering and values
remain intact; invalid name escapes fail closed. This is diagnostic projection,
not mutation of configured destinations or a claim to recognise arbitrary secrets.

Resolved ntfy must apply the same transport-error projection before both its
error log and returned error. Common HTTP execution preserves payload bytes,
event identity and error causes; URLs containing userinfo remain rejected by
outbound validation even though historical diagnostic userinfo is masked.

The caller matrix and retained Delivery regression tests exercise URL/message
helpers, actual rate-limit logs, common transport and resolved-ntfy transport
errors/logs with synthetic secrets. HTTP delivery-log regression verifies encoded
and repeated query credentials while retaining diagnostic context and entry
identity. Existing exact-output tables bound Slack/GovSlack/legacy, Discord,
Telegram/local paths, malformed URLs and non-secret lookalikes. Earlier proof
missed decoded query representations and a separate ntfy transport caller:
provider-only helper examples were not sufficient sink coverage. This contract
does not assert arbitrary response-body/third-party error secrecy, installed
recipient delivery, candidate qualification or historical customer exposure.
36 changes: 36 additions & 0 deletions internal/api/alerting/notifications_health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,39 @@ func containsAny(value string, needles ...string) bool {
}
return false
}

// These are response-boundary checks, not just URL-helper checks.
func TestGetDeliveryLogDiagnosticContext(t *testing.T) {
for _, tc := range []struct{ name, input, want string }{
{"plain", "connection refused", "connection refused"},
{"encoded repeated query", "Post https://example.test/hook?%74oken=secret&token=secret&channel=ops failed", "Post https://example.test/hook?%74oken=REDACTED&token=REDACTED&channel=ops failed"},
{"userinfo", "Post https://user:password@example.test/hook: timeout", "Post https://REDACTED@example.test/hook: timeout"},
{"malformed", "Post https://user:password@example.test/%zz: timeout", "[invalid webhook URL]"},
} {
t.Run(tc.name, func(t *testing.T) {
manager := new(MockNotificationManager)
monitor := new(MockNotificationMonitor)
monitor.On("GetNotificationManager").Return(manager).Once()
manager.On("GetDeliveryLog", mock.Anything, 0).Return([]notifications.DeliveryLogEntry{
{NotificationID: "attempt-1", ErrorMessage: tc.input, FailureClass: "transport"},
}, nil).Once()
rec := httptest.NewRecorder()
NewNotificationHandlers(nil, monitor).GetDeliveryLog(rec, httptest.NewRequest(http.MethodGet, "/api/notifications/delivery-log", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var response struct {
Entries []notifications.DeliveryLogEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Entries) != 1 || response.Entries[0].ErrorMessage != tc.want ||
response.Entries[0].NotificationID != "attempt-1" || response.Entries[0].FailureClass != "transport" {
t.Fatalf("unexpected delivery projection: %#v", response)
}
manager.AssertExpectations(t)
monitor.AssertExpectations(t)
})
}
}
1 change: 1 addition & 0 deletions internal/notifications/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,7 @@ func (n *NotificationManager) sendResolvedWebhookNtfy(webhook WebhookConfig, ale

resp, err := n.webhookClient.Do(req)
if err != nil {
err = redactWebhookTransportError(err)
log.Error().
Err(err).
Str("webhook", webhook.Name).
Expand Down
Loading
Loading