# KarmaDue dependency check as a GitHub composite Action (recipe)

This is a recipe to copy into your own repo. KarmaDue does not publish it as a Marketplace action.

Tested: 2026-11-12. We ran the script locally (Python 3, standard library only) against a fixture repo. `action.yml` was checked as valid YAML. It has **not** been run on GitHub's hosted runners yet.

What it does: it finds pinned dependencies (`name==version` in `requirements*.txt`, exact versions in `package.json`), asks KarmaDue's public preflight about each one (no account), prints GitHub annotations, and fails the job on `block`. A malicious-package report or a confirmed refusal for that exact version counts as `block`. With `fail-on: warn`, it also fails on advisories and unknowns.

## Files

`.github/actions/karmadue-check/action.yml`

```yaml
name: KarmaDue dependency check
description: Ask KarmaDue about pinned dependencies before merge. Fails on block (malicious report or confirmed refusal). Not a safety guarantee.
inputs:
  fail-on:
    description: "block (default) or warn (also fail on advisories/unknown)"
    default: block
  max-checks:
    description: Maximum dependencies checked per run
    default: "25"
  path:
    description: Directory to scan for requirements*.txt and package.json
    default: "."
runs:
  using: composite
  steps:
    - shell: bash
      env:
        KD_FAIL_ON: ${{ inputs.fail-on }}
        KD_MAX_CHECKS: ${{ inputs.max-checks }}
        KD_ROOT: ${{ inputs.path }}
      run: python3 "${{ github.action_path }}/kd_ci_check.py"
```

`.github/actions/karmadue-check/kd_ci_check.py`

```python
#!/usr/bin/env python3
"""KarmaDue CI check: asks KarmaDue's public preflight about pinned dependencies.
Reads requirements*.txt (name==version) and package.json (exact versions only).
Exit 1 if any answer is enforce=block (or, with KD_FAIL_ON=warn, anything but proceed_with_care).
Standard library only. No account or API key."""
import glob, json, os, re, sys, urllib.request

API = os.environ.get("KD_PREFLIGHT", "https://ogogoizwsfaduzehkshb.supabase.co/functions/v1/public-api/v1/preflight")
FAIL_ON = os.environ.get("KD_FAIL_ON", "block")          # block | warn
MAX = int(os.environ.get("KD_MAX_CHECKS", "25"))
ROOT = os.environ.get("KD_ROOT", ".")

def targets():
    out = []
    for f in glob.glob(os.path.join(ROOT, "**/requirements*.txt"), recursive=True):
        for line in open(f, encoding="utf-8", errors="ignore"):
            m = re.match(r"^\s*([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?\s*==\s*([A-Za-z0-9_.+\-!]+)", line)
            if m: out.append(f"pypi:{m.group(1)}=={m.group(2)}")
    for f in glob.glob(os.path.join(ROOT, "**/package.json"), recursive=True):
        if "node_modules" in f: continue
        try: pj = json.load(open(f, encoding="utf-8"))
        except Exception: continue
        for sec in ("dependencies", "devDependencies", "optionalDependencies"):
            for name, ver in (pj.get(sec) or {}).items():
                if isinstance(ver, str) and re.fullmatch(r"\d+\.\d+\.\d+[A-Za-z0-9.\-+]*", ver):
                    out.append(f"npm:{name}@{ver}")
    return list(dict.fromkeys(out))

def check(t):
    req = urllib.request.Request(API, data=json.dumps({"target": t, "action": "install"}).encode(),
                                 headers={"content-type": "application/json", "x-kd-via": "recipe-github-action"})
    try:
        with urllib.request.urlopen(req, timeout=20) as r: body = json.load(r)
        d = body.get("data") or {}
        return d.get("enforce") or "unknown", d.get("headline") or "", body.get("request_id") or ""
    except Exception as e:
        return "unknown", f"KarmaDue check failed: {e}", ""

ts = targets()
if len(ts) > MAX: print(f"::notice::KarmaDue: checking the first {MAX} of {len(ts)} pinned dependencies (KD_MAX_CHECKS)")
bad = 0
for t in ts[:MAX]:
    enf, head, rid = check(t)
    if enf == "block": lvl, bad = "error", bad + 1
    elif enf == "proceed_with_care": lvl = None
    else:
        lvl = "warning"
        if FAIL_ON == "warn": bad += 1
    print(f"{t}: {enf}" + (f" [{rid}]" if rid else ""))
    if lvl: print(f"::{lvl}::KarmaDue {t}: {head} [{rid}]")
print(f"KarmaDue: {len(ts[:MAX])} checked, {bad} failing (fail_on={FAIL_ON}). Not a safety guarantee.")
sys.exit(1 if bad else 0)
```

Workflow step:

```yaml
- uses: actions/checkout@v4
- uses: ./.github/actions/karmadue-check
  with:
    fail-on: block
```

## Real run (fixture: requests==2.31.0, urllib3==2.2.2, event-stream 3.3.6, left-pad 1.3.0, react ^18; 2026-11-12 ~2:05 AM ET)

    pypi:requests==2.31.0: require_human_approval
    ::warning::KarmaDue pypi:requests==2.31.0: Caution before you install requests==2.31.0: 3 published advisories affect requests@2.31.0. Fixed in 2.33.0. ...
    pypi:urllib3==2.2.2: require_human_approval
    ::warning::KarmaDue pypi:urllib3==2.2.2: Caution before you install urllib3==2.2.2: 8 published advisories affect urllib3@2.2.2. Fixed in 2.8.0. ...
    npm:event-stream@3.3.6: block
    ::error::KarmaDue npm:event-stream@3.3.6: Do not install event-stream@3.3.6: malicious-package report GHSA-mh6f-8j2x-4483 ...
    npm:left-pad@1.3.0: proceed_with_care
    KarmaDue: 4 checked, 1 failing (fail_on=block). Not a safety guarantee.
    (exit 1)

With `fail-on: warn`: 4 checked, 3 failing.

## Limits
- Only exact pins are checked. Ranges (`^18.0.0`, `flask>=2`) and lockfiles are skipped, and so are transitive dependencies.
- By default it checks at most 25 dependencies per run (`max-checks`). Anonymous reads are rate-limited at 30 per minute.
- `proceed_with_care` is not a safety clearance.
