Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1f3f981
fix(cooldown): add extract-deps regression fixture for nexus-mcp#160
j7an Apr 12, 2026
998554a
fix(cooldown): implement extract-deps.sh with bash-native parser
j7an Apr 12, 2026
96dcc5e
fix(cooldown): add Python and empty-diff extract-deps tests
j7an Apr 12, 2026
9ca390a
fix(cooldown): add check-release-age regression fixture for nexus-mcp…
j7an Apr 12, 2026
8beeb3d
fix(cooldown): implement check-release-age.sh with tier-1 GitHub/PyPI…
j7an Apr 12, 2026
747ce1f
fix(cooldown): add edge-case tests for check-release-age
j7an Apr 12, 2026
94f40fe
fix(ci): add bats test runner for shared-workflows scripts
j7an Apr 12, 2026
aab2e50
fix(cooldown): wire extract-deps.sh into dependency-cooldown.yml
j7an Apr 12, 2026
fc3f140
fix(cooldown): add cooldown_days input and wire check-release-age.sh
j7an Apr 12, 2026
d09b8fa
fix(cooldown): reconcile labels on every scan to fix stale-label bug
j7an Apr 12, 2026
22120ca
fix(cooldown): combine advisory and cooldown gates in state machine
j7an Apr 13, 2026
2790d8d
fix(cooldown): add Release Age and Extraction Warning comment sections
j7an Apr 13, 2026
9c3fe65
fix(docs): document cooldown_days, fail_on_cooldown, and cooldown-pen…
j7an Apr 13, 2026
9afccfd
fix(cooldown): run reconcile_label before HAS_ERROR early-exit
j7an Apr 13, 2026
053996e
fix(cooldown): add checkout, fix sanity-check dedup, guard reconcile …
j7an Apr 13, 2026
1ae4585
fix(cooldown): filter local/docker in sanity check, fix date fallback…
j7an Apr 13, 2026
df5d327
fix(ci): also run bats on dependency-cooldown.yml changes
j7an Apr 13, 2026
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
30 changes: 30 additions & 0 deletions .github/workflows/ci-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Script Tests

on:
pull_request:
paths:
- 'scripts/**'
- 'tests/**'
- '.github/workflows/ci-scripts.yml'
- '.github/workflows/dependency-cooldown.yml'

permissions:
contents: read

jobs:
bats:
runs-on: ubuntu-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit

- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install bats
uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # v4.0.0

- name: Run bats
run: bats tests/
252 changes: 190 additions & 62 deletions .github/workflows/dependency-cooldown.yml

Large diffs are not rendered by default.

62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Reusable GitHub Actions workflows for dependency management and security scannin

## Features

- **Native Dependabot cool-down** — configure the waiting period in `dependabot.yml`; Dependabot holds PRs until they mature
- **Dual-layer cool-down enforcement** — Dependabot's native `cooldown.default-days` gates PR creation; the workflow's `cooldown_days` input enforces release age on every scan, closing the rebase-bypass path that native cool-down alone leaves open
- **Version-aware advisory filtering** — advisories already patched at or below the PR's target version are collapsed into a non-blocking "historical" section
- **GHSA + OSV dual-source scan** — every package is queried against both GitHub Advisory and OSV.dev; mismatches surface both
- **OpenSSF Scorecard integration** — Scorecard results for each GitHub Action appear in the scan comment
Expand Down Expand Up @@ -77,6 +77,8 @@ jobs:
|-------|------|---------|-------------|
| `enable_scorecard` | boolean | `true` | Include OpenSSF Scorecard results for GitHub Actions in the scan comment |
| `auto_merge` | boolean | `false` | On clean scans, enable `gh pr merge --auto`; on dirty scans, apply the `security-review-needed` label |
| `cooldown_days` | number | `7` | Minimum release age in days before auto-merge is allowed. Set to `0` to disable workflow-side release-age enforcement (pre-v2.0.2 behavior — relies entirely on Dependabot's native cooldown) |
| `fail_on_cooldown` | boolean | `false` | If `true`, cooldown blocks set the gate status to `failure` instead of `pending`. Use when branch protection requires a hard-red blocker rather than a self-healing pending state |

## Supported Ecosystems

Expand Down Expand Up @@ -129,10 +131,66 @@ PR #23 added filtering so that advisories Dependabot has already fixed don't blo

## Cool-down configuration

All cool-down timing lives in `.github/dependabot.yml` and is enforced by Dependabot itself. There is no bypass label — to ship a zero-day fix immediately, lower `cooldown.default-days` (or remove it for the affected ecosystem) and let Dependabot re-run. Commit history on `dependabot.yml` is the audit trail.
The workflow enforces cool-down at two layers, each independently configurable:

### Layer 1 — Dependabot native cool-down (PR creation)

Configured in `.github/dependabot.yml`. Dependabot holds new PRs until the target version is at least `cooldown.default-days` old. This gate runs once, at PR creation. **It does NOT re-check on `@dependabot rebase`** — a rebase re-resolves to the latest version and skips this gate entirely.

```yaml
# .github/dependabot.yml
updates:
- package-ecosystem: "github-actions"
cooldown:
default-days: 7
```

See the [cool-down options reference](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown--) for per-severity (`semver-major-days`, `semver-minor-days`, `semver-patch-days`) and package include/exclude lists.

### Layer 2 — Workflow-side release-age gate (every scan)

Configured via the `cooldown_days` input on the caller workflow (default `7`). The workflow re-evaluates release age on every scan, including after `@dependabot rebase`. If any target version is younger than `cooldown_days`, the workflow:

- Applies the `cooldown-pending` label
- Sets the `dependency-cooldown / gate` status to `pending` (or `failure` if `fail_on_cooldown: true`)
- Skips `gh pr merge --auto` even if the advisory scan is clean

Once the next scan finds all versions past the threshold, the label is removed and the gate flips to `success`. To disable workflow-side enforcement entirely (pre-v2.0.2 behavior), set `cooldown_days: 0` in the caller workflow.

### Bypass

There is no per-PR bypass label. To ship a zero-day fix immediately:

- Lower `cooldown.default-days` in `.github/dependabot.yml` for the affected ecosystem, OR
- Set `cooldown_days: 0` in the caller workflow temporarily, OR
- Merge manually (the gate state is informational; you control your branch protection rules)

Commit history on `dependabot.yml` and your caller workflow is the audit trail.

## Labels

The workflow manages two labels on Dependabot PRs. **Both are reconciled on every scan** — applied when the condition is true, removed when it becomes false.

| Label | Color | Applied when | Removed when |
|-------|-------|--------------|--------------|
| `security-review-needed` | red (`B60205`) | Advisory scan finds vulnerabilities affecting target versions | Re-scan finds zero applicable advisories |
| `cooldown-pending` | amber (`FBCA04`) | Any target version is younger than `cooldown_days` | Re-scan finds all versions past the threshold |

The reconciliation is authoritative — a PR that was dirty at first scan and clean after rebase will have neither label at merge time.

## Recommended: scheduled re-scan for long-pending PRs

If a PR sits in `cooldown-pending` for multiple days, it will unblock automatically on the next push or `@dependabot rebase`. Consumers wanting time-based automatic re-scan (without waiting for a push) can add a `schedule:` trigger to their caller workflow:

```yaml
on:
pull_request:
schedule:
- cron: '0 */6 * * *' # every 6 hours
```

This is intentionally not shipped in the reusable workflow itself — cadence preferences vary per consumer. A 6-hour cadence handles a 7-day cooldown comfortably; tune as needed.

## Security Analysis (Zizmor)

This repo includes a [Zizmor](https://github.com/zizmorcore/zizmor) workflow that runs static security analysis on all workflow YAML files. It detects:
Expand Down
146 changes: 146 additions & 0 deletions scripts/check-release-age.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# check-release-age.sh — read dep TSV on stdin, emit verdict TSV on stdout
#
# Schema:
# in: <name>\t<version>\t<ecosystem>
# out: <name>\t<version>\t<ecosystem>\t<published_iso>\t<age_days>\t<verdict>\t<reason>
#
# Verdicts: pass | fail | error
# Exit: always 0; failures are per-row.
#
# Bash 3.2 compatible (macOS system bash).

set -uo pipefail

: "${COOLDOWN_DAYS:?COOLDOWN_DAYS env var is required}"
: "${NOW_EPOCH:=$(date +%s)}"

# iso_to_epoch <iso> — print unix epoch on stdout, return 1 on parse failure.
# Handles both GitHub ("2026-03-29T12:00:00Z") and PyPI ("2026-03-29T12:00:00")
# ISO variants across GNU and BSD date.
iso_to_epoch() {
local iso="$1"
# GNU date: accepts the ISO string as-is (with or without Z).
date -u -d "$iso" +%s 2>/dev/null && return 0
# GNU date: append Z for naive PyPI upload_time.
date -u -d "${iso}Z" +%s 2>/dev/null && return 0
# BSD date (macOS): strip trailing Z and parse via -jf.
local stripped="${iso%Z}"
date -u -jf '%Y-%m-%dT%H:%M:%S' "$stripped" +%s 2>/dev/null && return 0
return 1
}

# fetch_github <owner> <repo> <version> — print published_at ISO on stdout, return 1 on failure.
fetch_github() {
local owner="$1" repo="$2" version="$3"
if [ -n "${AGE_FIXTURE_DIR:-}" ]; then
local fx="$AGE_FIXTURE_DIR/github/$owner/$repo/releases/tags/v$version.json"
[ -f "$fx" ] || return 1
jq -r '.published_at // empty' "$fx"
return 0
fi
local resp
if ! resp=$(gh api "repos/$owner/$repo/releases/tags/v$version" 2>/dev/null); then
sleep 2
if ! resp=$(gh api "repos/$owner/$repo/releases/tags/v$version" 2>/dev/null); then
return 1
fi
fi
printf '%s' "$resp" | jq -r '.published_at // empty'
}

# fetch_pypi <pkg> <version> — print "<upload_time>\t<yanked_bool>" on stdout, return 1 on failure.
fetch_pypi() {
local pkg="$1" version="$2"
local upload yanked
if [ -n "${AGE_FIXTURE_DIR:-}" ]; then
local fx="$AGE_FIXTURE_DIR/pypi/$pkg/$version.json"
[ -f "$fx" ] || return 1
upload=$(jq -r '.urls[0].upload_time // empty' "$fx")
yanked=$(jq -r '.urls[0].yanked // false' "$fx")
printf '%s\t%s\n' "$upload" "$yanked"
return 0
fi
local resp
if ! resp=$(curl -sf --max-time 30 "https://pypi.org/pypi/$pkg/$version/json" 2>/dev/null); then
sleep 2
if ! resp=$(curl -sf --max-time 30 "https://pypi.org/pypi/$pkg/$version/json" 2>/dev/null); then
return 1
fi
fi
upload=$(printf '%s' "$resp" | jq -r '.urls[0].upload_time // empty')
yanked=$(printf '%s' "$resp" | jq -r '.urls[0].yanked // false')
printf '%s\t%s\n' "$upload" "$yanked"
}

# emit name version ecosystem published age verdict reason
emit() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" "$7"
}

while IFS=$'\t' read -r name version ecosystem || [ -n "${name:-}" ]; do
[ -z "${name:-}" ] && continue

# Escape hatch: COOLDOWN_DAYS=0 → all pass without lookup.
if [ "$COOLDOWN_DAYS" -eq 0 ]; then
emit "$name" "$version" "$ecosystem" "-" "-" "pass" ""
continue
fi

case "$ecosystem" in
actions)
owner="${name%%/*}"
remainder="${name#*/}"
repo="${remainder%%/*}"
if ! iso=$(fetch_github "$owner" "$repo" "$version"); then
emit "$name" "$version" "$ecosystem" "-" "-" "error" "tier-1-404"
continue
fi
if [ -z "$iso" ]; then
emit "$name" "$version" "$ecosystem" "-" "-" "error" "transient-failure"
continue
fi
if ! pub_epoch=$(iso_to_epoch "$iso"); then
emit "$name" "$version" "$ecosystem" "-" "-" "error" "parse-failure"
continue
fi
age_days=$(( (NOW_EPOCH - pub_epoch) / 86400 ))
if [ "$age_days" -ge "$COOLDOWN_DAYS" ]; then
emit "$name" "$version" "$ecosystem" "$iso" "$age_days" "pass" ""
else
emit "$name" "$version" "$ecosystem" "$iso" "$age_days" "fail" ""
fi
;;

pypi)
if ! result=$(fetch_pypi "$name" "$version"); then
emit "$name" "$version" "$ecosystem" "-" "-" "error" "pypi-404"
continue
fi
iso="${result%%$'\t'*}"
yanked="${result##*$'\t'}"
if [ -z "$iso" ]; then
emit "$name" "$version" "$ecosystem" "-" "-" "error" "transient-failure"
continue
fi
if ! pub_epoch=$(iso_to_epoch "$iso"); then
emit "$name" "$version" "$ecosystem" "-" "-" "error" "parse-failure"
continue
fi
age_days=$(( (NOW_EPOCH - pub_epoch) / 86400 ))
if [ "$yanked" = "true" ]; then
emit "$name" "$version" "$ecosystem" "$iso" "$age_days" "fail" "yanked"
elif [ "$age_days" -ge "$COOLDOWN_DAYS" ]; then
emit "$name" "$version" "$ecosystem" "$iso" "$age_days" "pass" ""
else
emit "$name" "$version" "$ecosystem" "$iso" "$age_days" "fail" ""
fi
;;

*)
emit "$name" "$version" "$ecosystem" "-" "-" "error" "unknown-ecosystem"
;;
esac
done

exit 0
85 changes: 85 additions & 0 deletions scripts/extract-deps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# extract-deps.sh — parse unified diff on stdin, emit dependency TSV on stdout
#
# Output: <name>\t<version>\t<ecosystem> where ecosystem ∈ {actions, pypi}
# Exit: 0 on success (possibly zero rows), 2 on malformed input
#
# Handles BOTH GitHub Actions shapes observed in real PR diffs:
# + uses: owner/repo@sha # vX.Y.Z (no list marker)
# + - uses: owner/repo@sha # vX.Y.Z (YAML list marker)
#
# The missing list-marker support in the v2.0.1 regex `^\+\s+uses:` is the
# root cause of issue #27 (astral-sh/setup-uv silently dropped from
# nexus-mcp#160's cooldown scan).

set -euo pipefail

# Dedup sentinel: newline-delimited list of "ecosystem:name" keys.
# Using a plain string (not `declare -A`) for bash 3.2 compatibility, since
# macOS's system bash is still 3.2 and the bats test invokes `bash` directly.
seen=$'\n'
rows=()

input=$(cat)

# Empty input → exit 0 with no output
if [ -z "$input" ]; then
exit 0
fi

# Malformed input detection: if non-empty and has none of the unified-diff
# markers, reject with exit 2.
if ! printf '%s\n' "$input" | grep -qE '^(\+\+\+|---|@@|diff --git)'; then
echo "extract-deps.sh: input is not a unified diff" >&2
exit 2
fi

while IFS= read -r line; do
# Strip CR for CRLF-encoded diffs
line="${line%$'\r'}"

# Skip diff file headers (+++ b/path)
[[ "$line" == +++* ]] && continue

# --- GitHub Actions parser ---
# Matches both "+ uses: foo@..." and "+ - uses: foo@..."
if [[ "$line" =~ ^\+[[:space:]]+(-[[:space:]]+)?uses:[[:space:]]+([^[:space:]@]+)@[^[:space:]]+(.*)$ ]]; then
name="${BASH_REMATCH[2]}"
rest="${BASH_REMATCH[3]}"

# Skip local and docker actions
[[ "$name" == ./* ]] && continue
[[ "$name" == docker://* ]] && continue

version=""
# Capture version from trailing comment: " # v1.2.3" or " # 1.2.3"
# The `v?` is OUTSIDE the capture group so we strip the leading v.
if [[ "$rest" =~ \#[[:space:]]*v?([0-9][0-9.]*) ]]; then
version="${BASH_REMATCH[1]}"
fi

key="actions:$name"
case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac
seen="${seen}${key}"$'\n'
rows+=("$name"$'\t'"$version"$'\t'"actions")
continue
fi

# --- Python deps parser ---
# Skip Python-style comment lines first
[[ "$line" =~ ^\+[[:space:]]*# ]] && continue

if [[ "$line" =~ ^\+[[:space:]]*([a-zA-Z][a-zA-Z0-9_.\-]*)[[:space:]]*(==|\>=|\<=|~=|\!=|\>|\<)[[:space:]]*([0-9][0-9a-zA-Z.\-]*) ]]; then
name="${BASH_REMATCH[1]}"
version="${BASH_REMATCH[3]}"
key="pypi:$name"
case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac
seen="${seen}${key}"$'\n'
rows+=("$name"$'\t'"$version"$'\t'"pypi")
continue
fi
done <<< "$input"

if [ ${#rows[@]} -gt 0 ]; then
printf '%s\n' "${rows[@]}" | sort -t$'\t' -k1,1
fi
41 changes: 41 additions & 0 deletions tests/check-release-age.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env bats

setup() {
export AGE_FIXTURE_DIR="tests/fixtures/check-release-age"
export NOW_EPOCH=1775995200 # 2026-04-12T12:00:00Z — verify with: date -u -r 1775995200
}

@test "blocks sub-cooldown actions at COOLDOWN_DAYS=7 (regression for #25)" {
export COOLDOWN_DAYS=7
run bash scripts/check-release-age.sh < tests/fixtures/check-release-age/nexus-mcp-160.tsv
[ "$status" -eq 0 ]
diff <(echo "$output") tests/fixtures/check-release-age/nexus-mcp-160-cooldown-7.tsv
}

@test "passes everything at COOLDOWN_DAYS=0 (escape hatch)" {
export COOLDOWN_DAYS=0
run bash scripts/check-release-age.sh < tests/fixtures/check-release-age/nexus-mcp-160.tsv
[ "$status" -eq 0 ]
diff <(echo "$output") tests/fixtures/check-release-age/nexus-mcp-160-cooldown-0.tsv
}

@test "PyPI happy path returns pass for aged release" {
export COOLDOWN_DAYS=7
run bash -c 'printf "requests\t2.32.5\tpypi\n" | bash scripts/check-release-age.sh'
[ "$status" -eq 0 ]
[[ "$output" =~ ^requests$'\t'2\.32\.5$'\t'pypi$'\t'.+$'\t'[0-9]+$'\t'pass$'\t'$ ]]
}

@test "yanked PyPI release fails regardless of age" {
export COOLDOWN_DAYS=7
run bash -c 'printf "yanked-pkg\t1.0.0\tpypi\n" | bash scripts/check-release-age.sh'
[ "$status" -eq 0 ]
[[ "$output" =~ ^yanked-pkg$'\t'1\.0\.0$'\t'pypi$'\t'.+$'\t'[0-9]+$'\t'fail$'\t'yanked$ ]]
}

@test "missing fixture (simulates 404) produces error verdict" {
export COOLDOWN_DAYS=7
run bash -c 'printf "no-such-action/does-not-exist\t1.0.0\tactions\n" | bash scripts/check-release-age.sh'
[ "$status" -eq 0 ]
[[ "$output" =~ ^no-such-action/does-not-exist$'\t'1\.0\.0$'\t'actions$'\t'-$'\t'-$'\t'error$'\t'tier-1-404$ ]]
}
Loading