From f3931da038c99fdd5588deecfe718dd921f00dc7 Mon Sep 17 00:00:00 2001 From: saagpatel <41898282+saagpatel@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:58:18 -0700 Subject: [PATCH 1/2] fix(ci): harden dependency watch contract --- .github/workflows/dependency-watch.yml | 72 ++++++++++++++++++- package.json | 3 +- .../ci/check-dependency-watch-contract.mjs | 22 ++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/check-dependency-watch-contract.mjs diff --git a/.github/workflows/dependency-watch.yml b/.github/workflows/dependency-watch.yml index 3bed37e..3ff9cbf 100644 --- a/.github/workflows/dependency-watch.yml +++ b/.github/workflows/dependency-watch.yml @@ -9,10 +9,15 @@ permissions: contents: read issues: write +concurrency: + group: dependency-watch-${{ github.repository }} + cancel-in-progress: false + jobs: dependency-watch: name: Weekly Dependency and Advisory Check runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -108,6 +113,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Create or update dependency alert issue + id: alert_issue if: steps.pnpm_audit.outputs.exit_code != '0' || steps.cargo_summary.outputs.warning_count != '0' || steps.cargo_summary.outputs.vuln_count != '0' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 env: @@ -149,22 +155,35 @@ jobs: const existing = issues.find(issue => issue.title === title); + let mutation; + let issueAction; if (existing) { - await github.rest.issues.update({ + mutation = await github.rest.issues.update({ owner, repo, issue_number: existing.number, body }); + issueAction = 'updated'; } else { - await github.rest.issues.create({ + mutation = await github.rest.issues.create({ owner, repo, title, body, labels: ['dependencies', 'security'] }); + issueAction = 'created'; + } + + const issueNumber = mutation.data.number; + const readback = await github.rest.issues.get({ owner, repo, issue_number: issueNumber }); + if (readback.data.title !== title || readback.data.body !== body) { + throw new Error(`Dependency alert issue readback mismatch for #${issueNumber}`); } + core.setOutput('issue_action', issueAction); + core.setOutput('issue_number', String(issueNumber)); + core.setOutput('destination_readback_verified', 'true'); - name: Upload dependency reports if: always() @@ -185,3 +204,52 @@ jobs: run: | echo "Dependency watch detected actionable vulnerabilities." exit 1 + + - name: Emit machine-readable completion state + if: always() + env: + JOB_STATUS: ${{ job.status }} + ISSUE_OUTCOME: ${{ steps.alert_issue.outcome }} + ISSUE_ACTION: ${{ steps.alert_issue.outputs.issue_action }} + ISSUE_NUMBER: ${{ steps.alert_issue.outputs.issue_number }} + ISSUE_READBACK: ${{ steps.alert_issue.outputs.destination_readback_verified }} + run: | + python3 <<'PY' + import json + import os + from datetime import datetime, timezone + + job_ok = os.environ.get("JOB_STATUS") == "success" + action = os.environ.get("ISSUE_ACTION", "") + issue_number = os.environ.get("ISSUE_NUMBER", "") + issue_outcome = os.environ.get("ISSUE_OUTCOME", "") + issue_attempted = issue_outcome not in ("", "skipped") + readback_required = issue_attempted + readback_verified = os.environ.get("ISSUE_READBACK") == "true" if readback_required else True + skipped = job_ok and not action + partial = issue_attempted and not readback_verified + payload = { + "schema": "AutomationTerminalStateV1", + "automation_id": "github:AssistSupport/dependency-watch", + "state": "succeeded" if job_ok else "failed", + "completed": job_ok, + "partial": partial, + "skipped": skipped, + "mutation_count": 1 if action else 0, + "destination_readback": { + "required": readback_required, + "verified": readback_verified, + "destination_id": f"issue:{issue_number}" if issue_number else None, + "evidence": {"issue_action": action or "unknown", "issue_step_outcome": issue_outcome}, + }, + "operator_action_required": not job_ok, + "can_auto_archive": job_ok, + "observed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + line = "automation_completion: " + json.dumps(payload, separators=(",", ":")) + print(line) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write("\n" + line + "\n") + if readback_required and not readback_verified: + raise SystemExit("dependency issue destination readback was not verified") + PY diff --git a/package.json b/package.json index 76c6ade..f8d9d51 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "pr:notes": "bash scripts/git/generate-pr-notes.sh", "check:workstation-preflight": "node scripts/ci/check-workstation-preflight.mjs", "check:workflow-drift": "node scripts/ci/check-workflow-command-drift.mjs", + "check:dependency-watch-contract": "node scripts/ci/check-dependency-watch-contract.mjs", "check:version-parity": "node scripts/ci/check-version-parity.mjs", "check:monorepo-readiness": "pnpm health:repo", "test": "vitest run", @@ -69,7 +70,7 @@ "ui:gate:regression": "pnpm test:e2e:smoke && pnpm ui:test:visual && pnpm ui:test:a11y && pnpm ui:test:responsive", "ui:gate": "pnpm ui:gate:static && pnpm ui:gate:regression", "ui:test:visual:update": "bash scripts/ui/run-playwright-tag.sh @visual --update-snapshots", - "health:repo:checks": "pnpm git:guard:branch && pnpm check:workstation-preflight && pnpm check:workflow-drift && pnpm check:version-parity", + "health:repo:checks": "pnpm git:guard:branch && pnpm check:workstation-preflight && pnpm check:workflow-drift && pnpm check:dependency-watch-contract && pnpm check:version-parity", "health:repo:static": "pnpm ui:gate:static", "health:repo:frontend": "pnpm test", "health:repo:search": "pnpm search-api:test", diff --git a/scripts/ci/check-dependency-watch-contract.mjs b/scripts/ci/check-dependency-watch-contract.mjs new file mode 100644 index 0000000..87423a6 --- /dev/null +++ b/scripts/ci/check-dependency-watch-contract.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import fs from "node:fs"; + +const workflow = fs.readFileSync(new URL("../../.github/workflows/dependency-watch.yml", import.meta.url), "utf8"); +const requirements = [ + ["workflow concurrency", /^concurrency:\n group:/m], + ["job timeout", /^ timeout-minutes: \d+/m], + ["mutation action output", /core\.setOutput\('issue_action'/], + ["destination id output", /core\.setOutput\('issue_number'/], + ["destination readback", /github\.rest\.issues\.get\(/], + ["readback mismatch failure", /throw new Error\(`Dependency alert issue readback mismatch/], + ["machine completion state", /AutomationTerminalStateV1/], + ["partial mutation accounting", /partial = issue_attempted and not readback_verified/], +]; + +const missing = requirements.filter(([, pattern]) => !pattern.test(workflow)).map(([name]) => name); +if (missing.length) { + console.error(`dependency-watch contract missing: ${missing.join(", ")}`); + process.exit(1); +} +console.log(JSON.stringify({ schema: "DependencyWatchContractTestV1", ok: true, tests: requirements.length })); From 0b2e91072e8c981eb0b16a667487035e769d9776 Mon Sep 17 00:00:00 2001 From: saagpatel <41898282+saagpatel@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:00:54 -0700 Subject: [PATCH 2/2] fix(ci): satisfy contract test lint --- scripts/ci/check-dependency-watch-contract.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/check-dependency-watch-contract.mjs b/scripts/ci/check-dependency-watch-contract.mjs index 87423a6..9a2520d 100644 --- a/scripts/ci/check-dependency-watch-contract.mjs +++ b/scripts/ci/check-dependency-watch-contract.mjs @@ -4,8 +4,8 @@ import fs from "node:fs"; const workflow = fs.readFileSync(new URL("../../.github/workflows/dependency-watch.yml", import.meta.url), "utf8"); const requirements = [ - ["workflow concurrency", /^concurrency:\n group:/m], - ["job timeout", /^ timeout-minutes: \d+/m], + ["workflow concurrency", /^concurrency:\n {2}group:/m], + ["job timeout", /^ {4}timeout-minutes: \d+/m], ["mutation action output", /core\.setOutput\('issue_action'/], ["destination id output", /core\.setOutput\('issue_number'/], ["destination readback", /github\.rest\.issues\.get\(/],