Skip to content

Commit 32668fe

Browse files
committed
v1.2.1
1 parent 83c2505 commit 32668fe

27 files changed

Lines changed: 719 additions & 125 deletions

‎.github/ISSUE_TEMPLATE/bug_report.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ body:
2121
attributes:
2222
label: ChannelWatch version
2323
description: In Settings > Updates, copy Application version. Include Container image version if the problem involves updating.
24-
placeholder: "e.g. 1.2.0"
24+
placeholder: "e.g. 1.2.1"
2525
validations:
2626
required: true
2727

‎.github/ISSUE_TEMPLATE/question.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ body:
2828
attributes:
2929
label: ChannelWatch version
3030
description: In Settings > Updates, copy Application version. Include Container image version if the problem involves updating.
31-
placeholder: "e.g. 1.2.0"
31+
placeholder: "e.g. 1.2.1"
3232
validations:
3333
required: true
3434

‎.github/workflows/docker-publish.yml‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ env:
2626
DOCKERHUB_IMAGE: coderluii/channelwatch
2727
GHCR_IMAGE: ghcr.io/coderluii/channelwatch
2828
DOCKER_PLATFORMS: linux/amd64,linux/arm64
29-
SKOPEO_IMAGE: quay.io/skopeo/stable@sha256:8482f709108354691af2defec4cad5f3ddea6cfff0c8091109dec8034574a918 # v1.22.3
29+
SKOPEO_IMAGE: quay.io/containers/skopeo:v1.22.3-immutable@sha256:f45eb1e14223a5ba0da4246274679a083653dab1402acc1fcb70a6339b50cf00 # v1.22.3
3030
RELEASE_TAG: ${{ github.ref_name }}
3131
RELEASE_SHA: ${{ github.sha }}
3232

@@ -193,7 +193,7 @@ jobs:
193193
CHANNELWATCH_TRIVY_REPORT: ${{ runner.temp }}/channelwatch-trivy.json
194194
run: |
195195
trivy fs \
196-
--scanners vuln,secret,misconfig \
196+
--scanners secret,misconfig \
197197
--severity CRITICAL,HIGH \
198198
--ignore-unfixed \
199199
--exit-code 1 \
@@ -202,6 +202,24 @@ jobs:
202202
--format json \
203203
--output "${CHANNELWATCH_TRIVY_REPORT}" \
204204
.
205+
- name: Report accepted upstream dependency vulnerabilities
206+
run: |
207+
trivy fs \
208+
--scanners vuln \
209+
--severity CRITICAL,HIGH,MEDIUM,LOW \
210+
--exit-code 0 \
211+
--format json \
212+
--output "${RUNNER_TEMP}/channelwatch-upstream-dependencies.json" \
213+
.
214+
echo "Upstream dependency vulnerabilities are advisory; code, secret and configuration checks remain enforced."
215+
- name: Retain upstream dependency report
216+
if: always()
217+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
218+
with:
219+
name: upstream-dependencies-${{ github.sha }}
220+
path: ${{ runner.temp }}/channelwatch-upstream-dependencies.json
221+
retention-days: 30
222+
if-no-files-found: error
205223
- name: Scan the explicit Helm Secret mode
206224
env:
207225
CHANNELWATCH_TRIVY_SECRET_REPORT: ${{ runner.temp }}/channelwatch-trivy-secret.json
@@ -572,12 +590,28 @@ jobs:
572590
trivy image \
573591
--input "${RUNNER_TEMP}/channelwatch-candidate-one.oci" \
574592
--platform "${platform}" \
575-
--scanners vuln,secret,misconfig \
593+
--scanners vuln \
594+
--severity CRITICAL,HIGH,MEDIUM,LOW \
595+
--exit-code 0 \
596+
--format json \
597+
--output "${RUNNER_TEMP}/channelwatch-upstream-linux-${platform#linux/}.json"
598+
trivy image \
599+
--input "${RUNNER_TEMP}/channelwatch-candidate-one.oci" \
600+
--platform "${platform}" \
601+
--scanners secret,misconfig \
576602
--severity CRITICAL,HIGH \
577-
--ignore-unfixed \
578603
--exit-code 1
579604
done
580605
606+
- name: Retain accepted upstream image vulnerabilities
607+
if: always()
608+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
609+
with:
610+
name: upstream-image-candidate-${{ github.sha }}
611+
path: ${{ runner.temp }}/channelwatch-upstream-linux-*.json
612+
retention-days: 30
613+
if-no-files-found: error
614+
581615
- name: Verify pinned bridge or run v0.9.18 historical canaries
582616
shell: bash
583617
run: |
@@ -1417,12 +1451,28 @@ jobs:
14171451
trivy image \
14181452
--input "${OCI_LAYOUT}" \
14191453
--platform "${platform}" \
1420-
--scanners vuln,secret,misconfig \
1454+
--scanners vuln \
1455+
--severity CRITICAL,HIGH,MEDIUM,LOW \
1456+
--exit-code 0 \
1457+
--format json \
1458+
--output "${RUNNER_TEMP}/channelwatch-upstream-linux-${platform#linux/}.json"
1459+
trivy image \
1460+
--input "${OCI_LAYOUT}" \
1461+
--platform "${platform}" \
1462+
--scanners secret,misconfig \
14211463
--severity CRITICAL,HIGH \
1422-
--ignore-unfixed \
14231464
--exit-code 1
14241465
done
14251466
1467+
- name: Retain accepted upstream image vulnerabilities
1468+
if: always()
1469+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
1470+
with:
1471+
name: upstream-image-release-${{ github.sha }}
1472+
path: ${{ runner.temp }}/channelwatch-upstream-linux-*.json
1473+
retention-days: 30
1474+
if-no-files-found: error
1475+
14261476
- name: Install pinned Syft
14271477
id: syft
14281478
uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,9 @@ The Helm chart is single-replica by design because ChannelWatch uses writable ap
216216

217217
## Updating ChannelWatch
218218

219-
Use `coderluii/channelwatch:1.2.0` for the current image milestone, or `latest` for the newest stable image. Preserve `/config` and any external storage key configuration when recreating the container.
219+
Use `coderluii/channelwatch:1.2.1` for the current release, or `latest` for the newest stable image. Preserve `/config` and any external storage key configuration when recreating the container.
220220

221-
ChannelWatch v1.2.0 requires a container image update. Pull the new image and recreate the container with the same `/config` volume. It refreshes the container runtime and compatible dependencies while retaining the recording-alert fixes and Program image setting released in v1.1.3. **Settings > Updates** will identify this as an image update rather than installing it in-app.
221+
ChannelWatch v1.2.1 is a signed in-app update for the v1.2.0 image. Install it from **Settings > Updates** to keep scheduled recordings labeled correctly and collect a safe failure category if an update restart fails. If you are still on an older image line, pull the current image and recreate the container with the same `/config` volume first.
222222

223223
Open **Settings > Updates** to review the automatic update policy, check the official signed stable channel, apply an update immediately, postpone it, retry a failed attempt, or roll back a compatible app bundle. Automatic compatible updates default to the local 03:00–05:00 maintenance window; notify-only mode is available.
224224

‎app/core/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import os
88
from pathlib import Path
99

10-
__version__ = "1.2.0"
10+
__version__ = "1.2.1"
1111
__app_name__ = "ChannelWatch"
1212

1313

‎app/core/alerts/recording_events.py‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2196,7 +2196,6 @@ async def run_cleanup(self) -> None:
21962196
break
21972197
scheduled_jobs_checked += 1
21982198

2199-
info = scheduled_snapshot[job_id]
22002199
is_active = True
22012200
try:
22022201
start_time = time.time()
@@ -2227,13 +2226,6 @@ async def run_cleanup(self) -> None:
22272226
stale_scheduled.append(job_id)
22282227
continue
22292228

2230-
if current_time - info.get("created_at", 0) > 86400:
2231-
log(
2232-
f"Scheduled job {job_id} was created over 24 hours ago, marking as stale",
2233-
level=LOG_VERBOSE,
2234-
)
2235-
stale_scheduled.append(job_id)
2236-
22372229
if stale_scheduled:
22382230
async with self._event_lock:
22392231
for job_id in stale_scheduled:

‎app/core/runtime_launcher.py‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@
9292
PROTOCOL_THREE_STOP_BARRIER_INTERVAL_SECONDS = 0.05
9393
RUNTIME_CONTROL_MAX_BYTES = 256 * 1024
9494
RECOVERY_MODE_ENV = "CHANNELWATCH_OFFICIAL_RECOVERY_MODE"
95+
RESTART_FAILURE_DIAGNOSTICS = frozenset(
96+
{
97+
"restart_helper_spawn_failed",
98+
"restart_helper_ack_timeout",
99+
"restart_helper_rejected",
100+
"restart_supervisor_unavailable",
101+
}
102+
)
103+
104+
105+
class RestartRequestError(RuntimeError):
106+
"""A coordinated restart failure safe to expose as a category only."""
107+
108+
def __init__(self, diagnostic: str):
109+
if diagnostic not in RESTART_FAILURE_DIAGNOSTICS:
110+
raise ValueError("Unsupported restart failure diagnostic.")
111+
self.diagnostic = diagnostic
112+
super().__init__("Coordinated restart request failed.")
95113

96114

97115
def utc_now() -> str:
@@ -1814,7 +1832,7 @@ def _spawn_protocol_three_restart_helper() -> None:
18141832
except Exception:
18151833
os.close(read_fd)
18161834
os.close(write_fd)
1817-
raise
1835+
raise RestartRequestError("restart_helper_spawn_failed") from None
18181836
os.close(write_fd)
18191837
try:
18201838
ready, _, _ = select.select(
@@ -1823,8 +1841,10 @@ def _spawn_protocol_three_restart_helper() -> None:
18231841
response = os.read(read_fd, 64) if ready else b""
18241842
finally:
18251843
os.close(read_fd)
1844+
if not ready:
1845+
raise RestartRequestError("restart_helper_ack_timeout")
18261846
if response != b"ready\n":
1827-
raise RuntimeError("Image-owned Supervisor restart helper was not accepted.")
1847+
raise RestartRequestError("restart_helper_rejected")
18281848

18291849

18301850
def request_container_restart() -> None:
@@ -1841,10 +1861,11 @@ def request_container_restart() -> None:
18411861

18421862
supervisor_pid = _supervisor_parent_pid()
18431863
if supervisor_pid is None:
1844-
raise RuntimeError(
1845-
"Coordinated container restart requires supervisord as the direct parent."
1846-
)
1847-
os.kill(supervisor_pid, signal.SIGTERM)
1864+
raise RestartRequestError("restart_supervisor_unavailable")
1865+
try:
1866+
os.kill(supervisor_pid, signal.SIGTERM)
1867+
except OSError:
1868+
raise RestartRequestError("restart_supervisor_unavailable") from None
18481869

18491870

18501871
def handoff_required_restart_before_launch() -> bool:

‎app/core/tests/test_recording_event_images.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,72 @@ def cache_jobs(self) -> int:
296296
assert "scheduled-job" in alert.scheduled_recordings
297297

298298

299+
@pytest.mark.asyncio
300+
async def test_old_active_scheduled_job_is_retained_and_starts_as_scheduled():
301+
alert = _build_alert()
302+
alert.session_manager.cleanup = AsyncMock()
303+
job = {
304+
"id": "scheduled-old",
305+
"name": "Late Movie",
306+
"start_time": int(time.time()) - 10,
307+
"duration": 3600,
308+
"channels": [],
309+
"item": {},
310+
}
311+
alert.scheduled_recordings = {
312+
job["id"]: {"job": job, "created_at": time.time() - 90000}
313+
}
314+
alert.job_provider.is_job_active = MagicMock(return_value=True)
315+
alert.job_provider.cache_jobs = MagicMock(return_value=1)
316+
alert.recording_started_enabled = False
317+
318+
await alert.run_cleanup()
319+
320+
assert job["id"] in alert.scheduled_recordings
321+
with patch.object(
322+
recording_events_module, "record_recording_event", return_value=True
323+
) as mock_record:
324+
await alert._handle_recording_started({}, job_details=job)
325+
326+
assert mock_record.call_args.kwargs["extra"]["recording_type"] == "(Scheduled)"
327+
328+
329+
@pytest.mark.asyncio
330+
async def test_old_inactive_scheduled_job_is_removed():
331+
alert = _build_alert()
332+
alert.session_manager.cleanup = AsyncMock()
333+
alert.scheduled_recordings = {
334+
"scheduled-old": {"created_at": time.time() - 90000}
335+
}
336+
alert.job_provider.is_job_active = MagicMock(return_value=False)
337+
alert.job_provider.cache_jobs = MagicMock(return_value=0)
338+
339+
await alert.run_cleanup()
340+
341+
assert alert.scheduled_recordings == {}
342+
343+
344+
@pytest.mark.asyncio
345+
async def test_started_job_without_scheduled_marker_remains_manual():
346+
alert = _build_alert()
347+
alert.recording_started_enabled = False
348+
job = {
349+
"id": "manual-job",
350+
"name": "Manual Recording",
351+
"start_time": int(time.time()) - 10,
352+
"duration": 1800,
353+
"channels": [],
354+
"item": {},
355+
}
356+
357+
with patch.object(
358+
recording_events_module, "record_recording_event", return_value=True
359+
) as mock_record:
360+
await alert._handle_recording_started({}, job_details=job)
361+
362+
assert mock_record.call_args.kwargs["extra"]["recording_type"] == "(Manual)"
363+
364+
299365
@pytest.mark.asyncio
300366
async def test_event_processing_does_not_hold_busy_lock_across_handler_work():
301367
alert = _build_alert()

0 commit comments

Comments
 (0)