Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
67 changes: 62 additions & 5 deletions .github/workflows/health-68-consumer-sync-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ on:
workflow_run:
workflows: [Merge Sync PRs]
types: [completed]
schedule:
# Daily self-healing cadence; workflow_run remains debounced below.
- cron: '15 6 * * *'
workflow_dispatch:
inputs:
repos:
Expand All @@ -24,10 +27,10 @@ on:

permissions:
contents: read
issues: write
actions: read

concurrency:
group: consumer-sync-drift-${{ github.repository }}-${{ github.ref }}
group: health-68-consumer-sync-drift
cancel-in-progress: true

jobs:
Expand All @@ -37,6 +40,8 @@ jobs:
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest
outputs:
drift_failed: ${{ steps.compare.outcome == 'failure' }}
steps:
- name: Checkout Workflows repo
uses: actions/checkout@v7
Expand All @@ -47,18 +52,47 @@ jobs:
secrets: ${{ toJSON(secrets) }}
github_token: ${{ github.token }}


- name: Debounce workflow_run fan-out
id: debounce
if: github.event_name == 'workflow_run'
env:
GH_TOKEN: ${{ github.token }}
DEBOUNCE_MINUTES: '30'
run: |
set -euo pipefail
latest_created="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/health-68-consumer-sync-drift.yml/runs?per_page=20" \
--jq '[.workflow_runs[] | select(.id != '"${GITHUB_RUN_ID}"') | select(.conclusion == "success" or .conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out")][0].created_at // empty']'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fix the unmatched quote in the debounce filter

For every qualifying workflow_run event, Bash parses this command before invoking gh and fails with unexpected EOF while looking for matching \'`because the jq expression ends withempty']'. Consequently the new debounce step fails, the comparison never runs, and this trigger cannot perform the drift check; keep the closing ]` inside the quoted jq expression.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not debounce against runs cancelled by this workflow

When two Merge Sync PRs completions arrive while the first Health 68 check is still running, the global concurrency group cancels that first run; the replacement then treats the freshly cancelled run as an executable prior run and skips its own comparison. The burst can therefore produce zero drift comparisons and leave drift undetected until a later trigger, so cancelled runs should not satisfy the debounce window (or the concurrency policy must not cancel the run being used as evidence).

Useful? React with 👍 / 👎.

)
if [ -z "$latest_created" ]; then
echo "No prior executable Health 68 run; continuing."
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
latest_epoch="$(date -d "$latest_created" +%s)"
now_epoch="$(date -u +%s)"
age_minutes=$(( (now_epoch - latest_epoch) / 60 ))
echo "Last executable Health 68 run was ${age_minutes} minutes ago at ${latest_created}."
if [ "$age_minutes" -lt "$DEBOUNCE_MINUTES" ]; then
echo "Skipping debounced workflow_run fan-out (< ${DEBOUNCE_MINUTES} minutes)."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"

- name: Setup Python
if: steps.debounce.outputs.skip != 'true'
uses: actions/setup-python@v7
with:
python-version: '3.14'

- name: Install dependencies
if: steps.debounce.outputs.skip != 'true'
run: pip install pyyaml requests

- name: Compare consumer repos to templates
id: compare
if: steps.debounce.outputs.skip != 'true'
env:
CONSUMER_SYNC_DRIFT_REPORT_JSON: artifacts/consumer-sync-drift-report.json
INPUT_REPOS: ${{ inputs.repos }}
Expand All @@ -74,16 +108,39 @@ jobs:
--report-json "$CONSUMER_SYNC_DRIFT_REPORT_JSON"

- name: Upload drift report
if: ${{ always() }}
if: ${{ always() && steps.debounce.outputs.skip != 'true' }}
uses: actions/upload-artifact@v7
with:
name: consumer-sync-drift-report
path: artifacts/consumer-sync-drift-report.json
if-no-files-found: warn
retention-days: 14

publish-drift:
name: Publish actionable drift tracker update
needs: check-drift
if: ${{ needs.check-drift.result == 'failure' }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Checkout Workflows repo
uses: actions/checkout@v7
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

- name: Setup API client
uses: ./.github/actions/setup-api-client
with:
secrets: ${{ toJSON(secrets) }}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github_token: ${{ github.token }}

- name: Download drift report
uses: actions/download-artifact@v6
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
with:
name: consumer-sync-drift-report
path: artifacts

- name: Create drift issue
if: ${{ failure() }}
uses: actions/github-script@v9
with:
script: |
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/health-71-sync-health-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,28 @@ jobs:
echo "**Hours since last success:** ${{ steps.check.outputs.hours_since_success }}"
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Assert durable tracker workflow liveness
id: tracker_liveness
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
pip install pyyaml
if python3 scripts/check_durable_tracker_liveness.py --comment-on-failure; then
echo "status=healthy" >> "$GITHUB_OUTPUT"
else
echo "status=unhealthy" >> "$GITHUB_OUTPUT"
exit 1
fi

- name: Tracker liveness summary
if: always() && steps.tracker_liveness.outcome == 'failure'
run: |
{
echo "## Durable tracker liveness"
echo ""
echo "At least one durable tracker source workflow has no executable run inside its configured cadence."
echo "FYI comments were posted on the affected tracker issue(s); no gate was opened or closed."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} >> "$GITHUB_STEP_SUMMARY"
21 changes: 21 additions & 0 deletions config/durable_tracker_liveness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Workflows monitored by Health 71 durable-tracker liveness assertions.
# Each entry must match a row in docs/ops/DURABLE_TRACKING_ISSUES.md.
trackers:
- workflow: agents-weekly-metrics.yml
issue: 2211
max_age_hours: 192
- workflow: maint-82-sync-dependency-campaign.yml
issue: 1836
max_age_hours: 1
- workflow: health-83-dependency-sync-efficiency.yml
issue: 2897
max_age_hours: 192
- workflow: health-68-consumer-sync-drift.yml
issue: 2210
max_age_hours: 48
- workflow: maint-69-sync-integration-repo.yml
issue: 2470
max_age_hours: 168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the invented cadence for the event-driven sync workflow

maint-69-sync-integration-repo.yml has only qualifying push and manual-dispatch triggers, as the durable-tracker table also documents; it has no weekly schedule. During any ordinary seven-day period without relevant template/config changes, Health 71 will therefore mark this healthy, idle workflow stale and append a misleading alert to issue #2470 every day. Either give the source workflow an actual cadence or exclude event-driven trackers from age-based liveness checks.

Useful? React with 👍 / 👎.

- workflow: maint-80-langsmith-metrics-dashboard.yml
issue: 2415
max_age_hours: 192
2 changes: 1 addition & 1 deletion docs/ops/DURABLE_TRACKING_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ failure-notification path.
| [#2211](https://github.com/stranske/Workflows/issues/2211) | Agent metrics weekly summary | [`agents-weekly-metrics.yml`](../../.github/workflows/agents-weekly-metrics.yml) | Mondays 06:00 UTC | New comment per run |
| [#1836](https://github.com/stranske/Workflows/issues/1836) | Sync/Dependabot campaign queue | [`maint-82-sync-dependency-campaign.yml`](../../.github/workflows/maint-82-sync-dependency-campaign.yml) + [`.github/scripts/sync_dependency_campaign.js`](../../.github/scripts/sync_dependency_campaign.js) | Every 10 minutes + Mondays 10:30 UTC | Body rewritten in place; due transient delivery lanes are serialized, while actionable delivery blockers do not suppress corrective candidate work |
| [#2897](https://github.com/stranske/Workflows/issues/2897) | Dependency/sync maintenance-efficiency advisory report | [`health-83-dependency-sync-efficiency.yml`](../../.github/workflows/health-83-dependency-sync-efficiency.yml) | Weekly Mondays 11:15 UTC + `workflow_dispatch` | Comment appended only on material-evidence fingerprint change |
| [#2210](https://github.com/stranske/Workflows/issues/2210) | 🔄 Consumer repo drift detected | [`health-68-consumer-sync-drift.yml`](../../.github/workflows/health-68-consumer-sync-drift.yml) | After each successful main-branch `Merge Sync PRs` run, qualifying manifest/template/script/tool pushes, or manual dispatch | Body rewritten in place, but only for actionable states |
| [#2210](https://github.com/stranske/Workflows/issues/2210) | 🔄 Consumer repo drift detected | [`health-68-consumer-sync-drift.yml`](../../.github/workflows/health-68-consumer-sync-drift.yml) | Daily 06:15 UTC schedule, debounced successful main-branch `Merge Sync PRs` runs, qualifying manifest/template/script/tool pushes, or manual dispatch | Body rewritten in place, but only for actionable states |
| [#2470](https://github.com/stranske/Workflows/issues/2470) | 🚨 Integration-Tests Sync Failed - Action Required | [`maint-69-sync-integration-repo.yml`](../../.github/workflows/maint-69-sync-integration-repo.yml) | On qualifying template/config pushes or manual dispatch | Stuck-window marker in body + recovery comment |
| [#2415](https://github.com/stranske/Workflows/issues/2415) | 📊 LangSmith Trace Coverage Dashboard | [`maint-80-langsmith-metrics-dashboard.yml`](../../.github/workflows/maint-80-langsmith-metrics-dashboard.yml) | Mondays 09:00 UTC + manual dispatch | Body rewritten in place |

Expand Down
175 changes: 175 additions & 0 deletions scripts/check_durable_tracker_liveness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Assert durable tracker source workflows executed inside their cadence."""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import yaml

REPO_ROOT = Path(__file__).resolve().parents[1]
CONFIG_PATH = REPO_ROOT / "config" / "durable_tracker_liveness.yml"
TRACKER_DOC = REPO_ROOT / "docs" / "ops" / "DURABLE_TRACKING_ISSUES.md"
EXECUTABLE_CONCLUSIONS = frozenset({"success", "failure", "cancelled", "timed_out"})


def _github_token() -> str:
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
if token:
return token
return subprocess.check_output(["gh", "auth", "token"], text=True).strip()


def _load_config() -> list[dict[str, Any]]:
data = yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8"))
trackers = data.get("trackers")
if not isinstance(trackers, list) or not trackers:
raise ValueError(f"{CONFIG_PATH} must define a non-empty trackers list")
return trackers


def tracker_doc_workflows() -> set[str]:
import re

text = TRACKER_DOC.read_text(encoding="utf-8")
section_match = re.search(
r"## Current durable trackers\n\n\| Issue.*?\n\|[-| ]+\n(.*?)(?:\n\n|\n### )",
text,
re.DOTALL,
)
assert section_match, "DURABLE_TRACKING_ISSUES.md is missing the tracker table"
table_body = section_match.group(1)
workflows = re.findall(r"\[`[^`]+`\]\([^)]*/([^/)]+)\)", table_body)
return {name for name in workflows if name.endswith(".yml")}


def _latest_executable_run(repo: str, workflow_file: str, token: str) -> dict[str, Any] | None:
raw = subprocess.check_output(
[
"gh",
"api",
f"repos/{repo}/actions/workflows/{workflow_file}/runs",
"-f",
"per_page=100",
Comment on lines +61 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force the workflow-runs request to use GET

In Health 71's new tracker check, passing -f per_page=100 without --method GET changes this gh api request into a POST. The installed CLI's gh api --help explicitly states that adding request parameters automatically switches the method to POST and that --method GET is required to send them as a query string. Since listing workflow runs is a GET endpoint, the first lookup raises CalledProcessError, so no trackers are evaluated or commented and the continue-on-error workflow only produces the generic failure summary.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on exact head 251c6bb: _latest_executable_run now invokes gh api --method GET ... -f per_page=100, so parameters remain a GET query. The focused Health 68/71 liveness validation passed after this correction. Leaving reviewer-owned thread resolution to the reviewer; no self-resolution performed.

"--jq",
".workflow_runs",
],
text=True,
env={**os.environ, "GH_TOKEN": token},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
runs = json.loads(raw)
if not isinstance(runs, list):
return None
for run in runs:
if not isinstance(run, dict):
continue
conclusion = str(run.get("conclusion") or "")
if conclusion in EXECUTABLE_CONCLUSIONS:
return run
return None


def _hours_since(iso_timestamp: str) -> float:
created = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
return (datetime.now(UTC) - created).total_seconds() / 3600.0


def evaluate_trackers(repo: str, token: str | None = None) -> list[dict[str, Any]]:
auth = token or _github_token()
results: list[dict[str, Any]] = []
for entry in _load_config():
workflow = str(entry["workflow"])
issue = int(entry["issue"])
max_age_hours = float(entry["max_age_hours"])
latest = _latest_executable_run(repo, workflow, auth)
if latest is None:
results.append(
{
"workflow": workflow,
"issue": issue,
"healthy": False,
"reason": "no executable run found (only action_required/skipped)",
}
)
continue
hours = _hours_since(str(latest["created_at"]))
healthy = hours <= max_age_hours
results.append(
{
"workflow": workflow,
"issue": issue,
"healthy": healthy,
"latest_conclusion": latest.get("conclusion"),
"latest_created_at": latest.get("created_at"),
"hours_since": round(hours, 2),
"max_age_hours": max_age_hours,
"run_url": latest.get("html_url"),
}
)
return results


def _comment_on_tracker(repo: str, issue: int, body: str, token: str) -> None:
subprocess.run(
["gh", "issue", "comment", str(issue), "--repo", repo, "--body", body],
check=True,
env={**os.environ, "GH_TOKEN": token},
)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "stranske/Workflows"))
parser.add_argument("--comment-on-failure", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)

configured = {str(entry["workflow"]) for entry in _load_config()}
documented = tracker_doc_workflows()
missing_from_config = sorted(documented - configured)
extra_in_config = sorted(configured - documented)
if missing_from_config or extra_in_config:
print(
"Tracker workflow coverage mismatch: "
f"missing_from_config={missing_from_config} extra_in_config={extra_in_config}",
file=sys.stderr,
)
return 2

token = _github_token()
results = evaluate_trackers(args.repo, token)
unhealthy = [item for item in results if not item.get("healthy")]

if args.json:
print(json.dumps({"results": results, "unhealthy_count": len(unhealthy)}, indent=2))
else:
for item in results:
status = "healthy" if item.get("healthy") else "UNHEALTHY"
print(f"{item['workflow']}\t{status}\t{item}")

if args.comment_on_failure and unhealthy:
for item in unhealthy:
body = (
"## Durable tracker liveness alert\n\n"
f"Source workflow `{item['workflow']}` has no executable run inside its "
f"{item.get('max_age_hours', '?')}h cadence.\n\n"
f"- Latest executable run: {item.get('latest_created_at', 'none')}\n"
f"- Conclusion: {item.get('latest_conclusion', 'n/a')}\n"
f"- Hours since: {item.get('hours_since', 'n/a')}\n"
f"- Run URL: {item.get('run_url', 'n/a')}\n\n"
"Confirm liveness from workflow run history, not tracker comment activity."
)
_comment_on_tracker(args.repo, int(item["issue"]), body, token)

return 1 if unhealthy else 0


if __name__ == "__main__":
raise SystemExit(main())
8 changes: 4 additions & 4 deletions tests/docs/test_durable_tracker_drift_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,17 @@ def test_tracker_doc_documents_every_state_the_checker_emits() -> None:
)


def test_tracker_doc_does_not_promise_a_cron_health_68_no_longer_has() -> None:
def test_tracker_doc_documents_daily_schedule_for_health_68() -> None:
text = TRACKER_DOC.read_text(encoding="utf-8")
workflow = HEALTH_68.read_text(encoding="utf-8")
row = _ROW_2210.search(text)
assert row, "missing #2210 cadence row in the durable tracker table"
cadence = row.group(1)

assert "schedule:" not in workflow, "Health 68 regained a cron; update the tracker doc row"
assert "05:10" not in cadence and "cron" not in cadence.lower()
assert "schedule:" in workflow, "Health 68 must declare a daily schedule trigger"
assert "daily" in cadence.lower() or "06:15" in cadence
assert "Merge Sync PRs" in cadence
assert "successful main-branch" in cadence
assert "successful main-branch" in cadence or "debounced" in cadence
assert "qualifying" in cadence and "push" in cadence
assert "manual dispatch" in cadence

Expand Down
Loading
Loading