diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e9853c3..405f8a0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,7 +4,7 @@ This file provides repository guidance for AI coding agents working in this repo ## What this repo is -`j7an/shared-workflows` publishes **reusable GitHub Actions workflows** that other repos consume via `uses: j7an/shared-workflows/.github/workflows/@v2`. There is no application code — the deliverables are the workflow YAMLs in `.github/workflows/` and the bash logic in `scripts/`. +`j7an/shared-workflows` publishes **reusable GitHub Actions workflows** that other repos consume via `uses: j7an/shared-workflows/.github/workflows/@v3`. There is no application code — the deliverables are the workflow YAMLs in `.github/workflows/` and the bash logic in `scripts/`. ## Commands @@ -30,8 +30,7 @@ A reusable workflow cannot reliably check out *its own* repo's scripts: in a `wo `scripts/*.sh` is the source of truth; the inline copy is a derived artifact. **Editing a script means updating its inline copy too**, or `check-inline-sync.sh` fails CI. The sync is byte-for-byte after known normalizations (10-space YAML indent strip, shebang strip, function-wrapper strip). The pairs are listed in `check-inline-sync.sh` (`INLINE_PAIRS`): -- `dependency-cooldown.yml` embeds `extract-deps.sh`, `check-release-age.sh`, `diff-touches-lockfile.sh`, `pr-body-to-deps.sh`, `classify-touched-paths.sh`, `pyproject-bump-extract.sh` -- `dependency-safety.yml` embeds the same six scripts plus `safety-verdict.sh` +- `dependency-safety.yml` embeds `extract-deps.sh`, `check-release-age.sh`, `diff-touches-lockfile.sh`, `pr-body-to-deps.sh`, `classify-touched-paths.sh`, `pyproject-bump-extract.sh`, and `safety-verdict.sh` - `tag-release.yml` embeds `bump-version-files.sh` `lint-workflow-call.sh` is the partner guard: it fails CI if any `workflow_call` file reintroduces a caller-scoped ref as a checkout `ref:`. @@ -40,9 +39,7 @@ A reusable workflow cannot reliably check out *its own* repo's scripts: in a `wo **Consumer-facing reusable workflows:** -- `dependency-safety.yml` — verifies the native-Dependabot-cooldown invariant on each Dependabot PR. Pipeline mirrors `dependency-cooldown.yml` (extract → fallback → guard → age check → GHSA/OSV scan → scorecard → comment → labels) but the verdict layer is deterministic: `failure` on age violation (when `fail_on_age_violation: true`), `error` on extraction/scan failure, `success` otherwise. Verdict translation lives in `safety-verdict.sh`. No rescan companion — verifier is single-shot per PR event. -- `dependency-cooldown.yml` — **legacy**, retained for Phase 2 migration window. Scans Dependabot PRs. Pipeline: parse diff → `extract-deps.sh` (with `pr-body-to-deps.sh` as fallback when the diff yields zero rows, and `diff-touches-lockfile.sh` as a fail-loud guard so a clean-but-wrong extraction can't produce a false-green gate) → `check-release-age.sh` for the cooldown gate → GHSA/OSV advisory scan → single update-or-create comment + label reconciliation. -- `cooldown-rescan.yml` — **legacy**, retained for Phase 2 migration window. Scheduled re-scan of PRs stuck in the `pending` cooldown state. +- `dependency-safety.yml` — verifies the native-Dependabot-cooldown invariant on each Dependabot PR. Pipeline: extract → fallback → guard → age check → GHSA/OSV scan → scorecard → comment → labels; the verdict layer is deterministic: `failure` on age violation (when `fail_on_age_violation: true`), `error` on extraction/scan failure, `success` otherwise. Verdict translation lives in `safety-verdict.sh`. No rescan companion — verifier is single-shot per PR event. - `tag-release.yml` — computes the next semver tag from Conventional Commits, optionally runs `bump-version-files.sh` against `.version-bump.json`, creates the tag via the GitHub Git Data API (so commits/tags auto-sign under the App identity). Requires a GitHub App key (`RELEASE_BOT_PRIVATE_KEY` secret, `RELEASE_BOT_APP_ID` var). - `publish-pypi.yml` — `uv build` → TestPyPI (with install verification) → PyPI via OIDC trusted publishing → GitHub Release. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ec37539..e2c692e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,6 +1,8 @@ # Reusable Workflows -This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers reference them via `uses: j7an/shared-workflows/.github/workflows/@v2`. +This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers reference them via `uses: j7an/shared-workflows/.github/workflows/@v3`. + +> **Note:** `@v2` continues to work for `tag-release.yml`, `publish-pypi.yml`, and `dependency-safety.yml` at their last-released v2 revision, but receives no further updates. ## `tag-release.yml` @@ -30,7 +32,7 @@ on: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v2 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 with: bump: ${{ inputs.bump }} # tag-prefix omitted → defaults to "v" → produces v1.2.3 @@ -47,7 +49,7 @@ on: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v2 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 with: bump: ${{ inputs.bump }} tag-prefix: "tools/v" # produces tools/v0.1.0 @@ -184,7 +186,7 @@ on: jobs: publish: - uses: j7an/shared-workflows/.github/workflows/publish-pypi.yml@v2 + uses: j7an/shared-workflows/.github/workflows/publish-pypi.yml@v3 with: tag: ${{ github.ref_name }} package-dir: tools @@ -196,7 +198,7 @@ jobs: For each new PyPI package that uses this workflow, complete **once**: - [ ] Claim the package name on [PyPI](https://pypi.org/) and [TestPyPI](https://test.pypi.org/). -- [ ] On PyPI, configure trusted publisher: workflow `j7an/shared-workflows/.github/workflows/publish-pypi.yml`, ref `v2`, environment `pypi`. +- [ ] On PyPI, configure trusted publisher: workflow `j7an/shared-workflows/.github/workflows/publish-pypi.yml`, ref `v3`, environment `pypi`. - [ ] On TestPyPI, configure the same trusted publisher with environment `testpypi`. - [ ] Confirm GitHub Environments `testpypi` and `pypi` exist in `j7an/shared-workflows` repo settings. diff --git a/.github/workflows/cooldown-rescan.yml b/.github/workflows/cooldown-rescan.yml deleted file mode 100644 index 424180e..0000000 --- a/.github/workflows/cooldown-rescan.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Dependency Cooldown Rescan - -on: - workflow_call: - inputs: - dry_run: - type: boolean - default: false - description: "When true, log decisions to the step summary but do not call `gh run rerun`. Use for initial-rollout validation and incident diagnostics." - -concurrency: - group: cooldown-rescan-${{ github.repository }} - cancel-in-progress: false - -jobs: - rescan: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - actions: write - statuses: read - steps: - - name: Harden runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 - with: - egress-policy: audit - - - name: Rerun pending cooldown scans - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -uo pipefail - - # Sweep open Dependabot PRs and rerun any whose `dependency-cooldown / gate` - # commit status is `pending`. Per-PR work uses `set +e` so one PR's failure - # never aborts the sweep. - - { - echo "## Cooldown Rescan Sweep" - echo "" - if [ "$DRY_RUN" = "true" ]; then - echo "_Dry-run mode: no \`gh run rerun\` calls will be made._" - echo "" - fi - echo "| PR | Head SHA | Gate | Action | Result |" - echo "|----|----------|------|--------|--------|" - } >> "$GITHUB_STEP_SUMMARY" - - # 1) List open Dependabot PRs (loud failure on outer error). - if ! PRS_JSON=$(gh pr list \ - --repo "$GH_REPO" \ - --author app/dependabot \ - --state open \ - --json number,headRefName,headRefOid 2>&1); then - echo "FATAL: gh pr list failed: $PRS_JSON" >&2 - exit 1 - fi - - PR_COUNT=$(echo "$PRS_JSON" | jq 'length') - if [ "$PR_COUNT" -eq 0 ]; then - echo "| — | — | — | no-prs | no Dependabot PRs to sweep |" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - # 2) Per-PR loop. Pipe-to-while runs in a subshell, which is fine - # since we don't need to carry state out of the loop. - echo "$PRS_JSON" | jq -c '.[]' | while read -r PR; do - PR_NUM=$(echo "$PR" | jq -r '.number') - HEAD_REF=$(echo "$PR" | jq -r '.headRefName') - HEAD_SHA=$(echo "$PR" | jq -r '.headRefOid') - HEAD_SHA_SHORT="${HEAD_SHA:0:7}" - - set +e - - # 2a) Combined commit status — find the cooldown gate context. - STATUS_JSON=$(gh api "repos/${GH_REPO}/commits/${HEAD_SHA}/status" 2>&1) - if [ $? -ne 0 ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | — | skip | status-fetch-failed |" >> "$GITHUB_STEP_SUMMARY" - set -e - continue - fi - - GATE=$(echo "$STATUS_JSON" | jq -r \ - '.statuses[] | select(.context == "dependency-cooldown / gate") | .state' \ - | head -1) - - if [ -z "$GATE" ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | — | skip | no-gate-status |" >> "$GITHUB_STEP_SUMMARY" - set -e - continue - fi - - if [ "$GATE" != "pending" ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | ${GATE} | skip | gate-not-pending |" >> "$GITHUB_STEP_SUMMARY" - set -e - continue - fi - - # 2b) Find the most recent dependency-cooldown.yml run for this branch. - RUN_JSON=$(gh run list \ - --repo "$GH_REPO" \ - --workflow=.github/workflows/dependency-cooldown.yml \ - --branch="$HEAD_REF" \ - --limit 1 \ - --json databaseId,status,conclusion 2>&1) - if [ $? -ne 0 ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | pending | skip | run-list-failed |" >> "$GITHUB_STEP_SUMMARY" - set -e - continue - fi - - RUN_ID=$(echo "$RUN_JSON" | jq -r '.[0].databaseId // empty') - RUN_STATUS=$(echo "$RUN_JSON" | jq -r '.[0].status // empty') - - if [ -z "$RUN_ID" ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | pending | skip | no-prior-run |" >> "$GITHUB_STEP_SUMMARY" - set -e - continue - fi - - if [ "$RUN_STATUS" != "completed" ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | pending | skip | run-in-progress |" >> "$GITHUB_STEP_SUMMARY" - set -e - continue - fi - - # 2c) Rerun (or echo in dry-run). - if [ "$DRY_RUN" = "true" ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | pending | dry-run | would-rerun-${RUN_ID} |" >> "$GITHUB_STEP_SUMMARY" - else - RERUN_OUT=$(gh run rerun "$RUN_ID" --repo "$GH_REPO" 2>&1) - if [ $? -eq 0 ]; then - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | pending | rerun | run-${RUN_ID}-rerun |" >> "$GITHUB_STEP_SUMMARY" - else - echo "| #${PR_NUM} | ${HEAD_SHA_SHORT} | pending | rerun | rerun-failed |" >> "$GITHUB_STEP_SUMMARY" - echo "rerun failure detail for PR #${PR_NUM}: ${RERUN_OUT}" >&2 - fi - fi - - set -e - done - - exit 0 diff --git a/.github/workflows/dependency-cooldown.yml b/.github/workflows/dependency-cooldown.yml deleted file mode 100644 index 490fb66..0000000 --- a/.github/workflows/dependency-cooldown.yml +++ /dev/null @@ -1,1679 +0,0 @@ -name: Dependency Cool-Down - -on: - workflow_call: - inputs: - enable_scorecard: - type: boolean - default: true - description: "Include OpenSSF Scorecard in scan results" - auto_merge: - type: boolean - default: false - description: "Auto-merge clean PRs after scan completes" - cooldown_days: - type: number - default: 7 - description: "Minimum release age in days before auto-merge is allowed. 0 disables the release-age gate entirely (pre-v2.0.2 behavior)." - fail_on_cooldown: - type: boolean - default: false - description: "If true, cooldown blocks set the gate to failure instead of pending. Use when branch protection requires a hard-red blocker." - -jobs: - scan: - runs-on: ubuntu-latest - # Gate discipline (see #29): `Set initial status` must be the first - # step after `Harden runner`. Every subsequent step must carry - # `if: steps.gate.outputs.skip == 'false'` unless it is itself part - # of the gate. This avoids making non-bot PRs pay for fallible work - # (cross-repo fetches, API calls, script execution) that would only - # be used for Dependabot PRs. - steps: - - name: Harden runner - uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 - with: - egress-policy: audit - - - name: Set initial status - id: gate - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - run: | - if [[ "$PR_AUTHOR" != "dependabot[bot]" ]]; then - gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ - -f state="success" \ - -f context="dependency-cooldown / gate" \ - -f description="Non-bot PR — no cool-down required" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ - -f state="pending" \ - -f context="dependency-cooldown / gate" \ - -f description="Scanning dependencies for known exploits..." - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - - name: Scan and report - if: steps.gate.outputs.skip == 'false' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - ENABLE_SCORECARD: ${{ inputs.enable_scorecard }} - AUTO_MERGE: ${{ inputs.auto_merge }} - COOLDOWN_DAYS: ${{ inputs.cooldown_days }} - FAIL_ON_COOLDOWN: ${{ inputs.fail_on_cooldown }} - run: | - # --- BEGIN inline:scripts/extract-deps.sh --- - extract_deps() ( - # extract-deps.sh — parse unified diff on stdin, emit dependency TSV on stdout - # - # Output: \t\t where ecosystem ∈ {actions, pypi} - # Exit: 0 on success (possibly zero rows), 2 on malformed input - # - # Handles THREE shapes observed in real PR diffs: - # 1. GitHub Actions `uses:` lines (single-line name@version) - # 2. pip/requirements `name==version` single-line format - # 3. TOML lockfile [[package]] stanzas (uv.lock, poetry.lock) where the - # name line may be on an unchanged context line while only the version - # is `+`-prefixed — section-aware parsing required. - # - # The TOML lockfile parser addresses issue #52: Dependabot bumps to uv.lock - # and poetry.lock previously yielded zero rows, cascading to silent-green - # cooldown gates on unscanned code. - - set -euo pipefail - - # pypi-shape lockfiles only. Cargo.lock / package-lock.json would need - # downstream ecosystem support (registry clients, OSV enums) before rows - # could be scanned — the fail-loud guard in dependency-cooldown.yml covers - # unhandled lockfiles. - filename_to_ecosystem() { - case "$1" in - uv.lock|poetry.lock) echo "pypi" ;; - *) echo "" ;; - esac - } - - # Dedup sentinel: newline-delimited list of "ecosystem:name" keys. - # Plain string (not `declare -A`) for bash 3.2 compatibility — macOS ships - # bash 3.2 and bats invokes bash directly. - seen=$'\n' - rows=() - - # Lockfile parser state - current_file="" - current_name="" - current_ecosystem="" - - input=$(cat) - - # Empty input → exit 0 with no output - if [ -z "$input" ]; then - exit 0 - fi - - # Malformed input detection: here-string (not pipeline) to avoid SIGPIPE - # under pipefail on large valid diffs (issue #50). - if ! grep -qE '^(\+\+\+|---|@@|diff --git)' <<< "$input"; 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'}" - - # --- Branch 1: diff --git file header — track current file/ecosystem --- - if [[ "$line" =~ ^diff[[:space:]]--git[[:space:]]a/([^[:space:]]+)[[:space:]]b/([^[:space:]]+) ]]; then - current_file="${BASH_REMATCH[2]##*/}" - current_ecosystem=$(filename_to_ecosystem "$current_file") - current_name="" - continue - fi - - # Skip diff file headers (+++ b/path) - [[ "$line" == +++* ]] && continue - - # --- Branch 3: [[package]] stanza boundary (context or +, not -) --- - if [ -n "$current_ecosystem" ] && [[ "$line" =~ ^[+\ ][[:space:]]*\[\[package\]\] ]]; then - current_name="" - continue - fi - - # --- Branch 4: name = "..." line (context or +, not -) --- - if [ -n "$current_ecosystem" ] && [[ "$line" =~ ^[+\ ][[:space:]]*name[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then - current_name="${BASH_REMATCH[1]}" - continue - fi - - # --- Branch 5: + version = "..." line (only + lines emit rows) --- - if [ -n "$current_ecosystem" ] && [ -n "$current_name" ] \ - && [[ "$line" =~ ^\+[[:space:]]*version[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then - key="$current_ecosystem:$current_name" - case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac - seen="${seen}${key}"$'\n' - rows+=("$current_name"$'\t'"${BASH_REMATCH[1]}"$'\t'"$current_ecosystem") - continue - fi - - # --- GitHub Actions parser (existing, unchanged) --- - if [[ "$line" =~ ^\+[[:space:]]+(-[[:space:]]+)?uses:[[:space:]]+([^[:space:]@]+)@[^[:space:]]+(.*)$ ]]; then - name="${BASH_REMATCH[2]}" - rest="${BASH_REMATCH[3]}" - - [[ "$name" == ./* ]] && continue - [[ "$name" == docker://* ]] && continue - - version="" - 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 (existing, unchanged) --- - [[ "$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 - ) - # --- END inline:scripts/extract-deps.sh --- - - # --- BEGIN inline:scripts/diff-touches-lockfile.sh --- - diff_touches_lockfile() ( - # diff-touches-lockfile.sh — detect dependency-relevant file edits in a - # unified diff (lockfiles, manifests, and GitHub Actions workflow YAMLs). - # Used by dependency-cooldown.yml's fail-loud guard to refuse green gates - # when the dep extractor returns zero rows on a diff that touches files - # which *should* have produced deps. - # - # Input: unified diff on stdin - # Output: matched paths (b/ side of diff --git headers), sorted & deduped, - # one per line; empty stdout if nothing matched. - # Exit: 0 if any dependency-relevant path touched, 1 if none, 2 on malformed input. - - set -euo pipefail - - input=$(cat) - - if [ -z "$input" ]; then - exit 1 - fi - - if ! grep -qE '^diff --git ' <<< "$input"; then - echo "diff-touches-lockfile.sh: input is not a unified diff" >&2 - exit 2 - fi - - matches=() - while IFS= read -r path; do - base="${path##*/}" - # Add new lockfile/manifest filename patterns to this case list as needed. - case "$base" in - *.lock|requirements*.txt|pyproject.toml|Pipfile|go.mod|Cargo.toml \ - |package.json|package-lock.json|yarn.lock|pnpm-lock.yaml) - matches+=("$path") - continue - ;; - esac - # GitHub Actions workflow YAMLs are dependency declarations (uses: lines). - # Matched by full path, not basename, to avoid over-triggering on any *.yml. - case "$path" in - .github/workflows/*.yml|.github/workflows/*.yaml) - matches+=("$path") - ;; - esac - done < <(grep -oE '^diff --git a/[^ ]+ b/[^ ]+' <<< "$input" \ - | sed -E 's|^diff --git a/[^ ]+ b/||') - - if [ ${#matches[@]} -eq 0 ]; then - exit 1 - fi - - printf '%s\n' "${matches[@]}" | sort -u - ) - # --- END inline:scripts/diff-touches-lockfile.sh --- - - # --- BEGIN inline:scripts/classify-touched-paths.sh --- - classify_touched_paths() ( - # classify-touched-paths.sh — filter dependency-relevant paths down to - # those that extract-deps.sh does NOT parse. - # - # Input: newline-delimited paths on stdin (typically the output of - # diff-touches-lockfile.sh). - # Output: subset of input paths whose filename/location is NOT in the - # extract-deps.sh supported set, sorted & deduped, one per line. - # Empty stdout when every input path is supported. - # Exit: 0 on success (zero or more output rows). No exit-2 path: malformed - # input here is "an unrecognised filename" which is exactly what we - # report on stdout. - # - # Supported set (must stay in sync with scripts/extract-deps.sh): - # - .github/workflows/*.yml | *.yaml (GitHub Actions `uses:` line parser) - # - uv.lock | poetry.lock (TOML [[package]] stanza parser) - # - requirements*.txt (pip line-shape parser) - # - # `requirements*.txt` is supported by line-shape parsing, not by a full - # requirements-file parser. The extractor recognizes added requirement lines - # with operators ==, >=, <=, ~=, !=, >, or < and a numeric version prefix; - # comments, includes like `-r other.txt`, hash/check option lines, and other - # requirements-file structure are ignored. Standard Dependabot bumps reliably - # surface as added pinned requirement lines, which is what we promise. - # - # Everything else diff-touches-lockfile.sh emits — pyproject.toml, Pipfile, - # Pipfile.lock, go.mod, Cargo.toml, Cargo.lock, package.json, - # package-lock.json, yarn.lock, pnpm-lock.yaml, and any other *.lock — is - # unsupported and printed to stdout. Layer 2 (PR-body fallback) may still - # recover deps from some of these for the scan loop, but the guard fires - # because the diff parser cannot prove the scan was complete. - # - # pyproject.toml is path-level unsupported here and may be cleared by - # scripts/pyproject-bump-extract.sh at the workflow composition layer - # when its hunks are proven to be bump-only. This script remains - # path-only and intentionally conservative; the final unsupported set - # in the workflow is produced by composition, not by this classifier - # alone. - - set -euo pipefail - - while IFS= read -r path || [ -n "$path" ]; do - [ -z "$path" ] && continue - base="${path##*/}" - - # Supported (drop from output). - case "$path" in - .github/workflows/*.yml|.github/workflows/*.yaml) continue ;; - esac - case "$base" in - uv.lock|poetry.lock) continue ;; - requirements*.txt) continue ;; - esac - - # Anything else: emit as unsupported. - printf '%s\n' "$path" - done | sort -u - ) - # --- END inline:scripts/classify-touched-paths.sh --- - - # --- BEGIN inline:scripts/pyproject-bump-extract.sh --- - pyproject_bump_extract() ( - # pyproject-bump-extract.sh — diff-aware extractor for pyproject.toml bump-only edits. - # - # Owns all pyproject.toml diff semantics for the dependency-cooldown/safety - # workflows. Recognizes the narrow set of bump shapes Dependabot emits for - # uv/poetry ecosystems and emits either extracted dep rows (mode=deps) or - # the paths it proved are bump-only (mode=cleared-paths). Files with any - # unparseable changed line (build-system edits, new-dep additions, marker - # changes, etc.) are disqualified and left unsupported so the existing - # fail-loud guard fires. - # - # Input: unified diff on stdin - # Flag: --mode=deps OR --mode=cleared-paths (exactly one, required) - # Output (deps): TSV \t\tpypi, sorted by name, deduped - # Output (cleared-paths): newline-delimited pyproject.toml paths, sorted, deduped - # Exit: 0 on success (possibly zero rows / zero paths) - # 2 on malformed input, missing --mode, unknown mode, or repeated --mode - # - # Bash 3.2 compatible: no `declare -A`, no `mapfile`/`readarray`. Dedup uses a - # newline-delimited string sentinel, matching scripts/extract-deps.sh. - # - # See docs/superpowers/specs/2026-05-23-pyproject-toml-parser-design.md. - - set -euo pipefail - - MODE="" - for arg in "$@"; do - case "$arg" in - --mode=deps|--mode=cleared-paths) - if [ -n "$MODE" ]; then - echo "pyproject-bump-extract.sh: --mode specified more than once" >&2 - exit 2 - fi - MODE="${arg#--mode=}" - ;; - *) - echo "pyproject-bump-extract.sh: unknown argument: $arg" >&2 - exit 2 - ;; - esac - done - - if [ -z "$MODE" ]; then - echo "pyproject-bump-extract.sh: --mode=deps or --mode=cleared-paths required" >&2 - exit 2 - fi - - input=$(cat) - - # Empty input → exit 0 with no output (matches extract-deps.sh). - if [ -z "$input" ]; then - exit 0 - fi - - # Malformed input detection: here-string (not pipeline) to avoid SIGPIPE under - # pipefail on large valid diffs (issue #50 pattern). - if ! grep -qE '^(\+\+\+|---|@@|diff --git)' <<< "$input"; then - echo "pyproject-bump-extract.sh: input is not a unified diff" >&2 - exit 2 - fi - - # --- Parser state --- - # Per-file (reset on each diff --git boundary): - current_path="" - current_basename="" - current_table="" # "" | "project_other" | "project_optional_deps" | "dependency_groups" - # | "tool_uv" | "poetry_main" | "poetry_group" | "poetry_dev" - # | "build_system" | "other" - current_key="" # array-opening key for tables where keys are dep arrays - verdict="clean" - file_rows=$'\n' - - # Global: - out_deps_rows=() - out_cleared_paths=() - seen=$'\n' # dedup sentinel "\npypi:\n" - - # Single pending tracker. Lifetime = exactly one line. If pending is set, - # the IMMEDIATELY NEXT line MUST be the matching + (same kind, same name, - # same skeleton). Anything else (other -, unmatched +, context, comment, - # header, hunk boundary, file boundary, EOF) disqualifies. - pending_kind="" # "" | "pep508" | "poetry_keyval" | "poetry_inline" - pending_name="" - pending_skeleton="" # entry with version-spec field substituted by sentinel - pending_minus_version="" - - # --- Helpers --- - - # extract_target_version "$spec" — §3.3.1. Prints target on stdout, returns 1 on disqualify. - extract_target_version() { - local spec="$1" stripped - spec="${spec#"${spec%%[![:space:]]*}"}" - spec="${spec%"${spec##*[![:space:]]}"}" - case "$spec" in *,*) return 1 ;; esac - case "$spec" in - \^*) stripped="${spec#\^}" ;; - \~=*) stripped="${spec#~=}" ;; - \~*) stripped="${spec#~}" ;; - \>=*) stripped="${spec#>=}" ;; - ==*) stripped="${spec#==}" ;; - \<*|\<=*|\>*|!=*) return 1 ;; - *) stripped="$spec" ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - if [[ "$stripped" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|alpha|beta)[0-9]*)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]]; then - printf '%s' "$stripped" - return 0 - fi - return 1 - } - - # parse_pep508_entry "$content" — captures name/extras/version_spec/marker. - # Sets _pep_name, _pep_extras (with brackets, or empty), _pep_version_spec, - # _pep_marker (with leading ';', or empty). Returns 0/1. - parse_pep508_entry() { - local content="$1" trimmed - _pep_name=""; _pep_extras=""; _pep_version_spec=""; _pep_marker="" - if [[ "$content" =~ ^[[:space:]]*\"([A-Za-z][A-Za-z0-9_.-]*)(\[[^]]*\])?([^\";]*)(;.*)?\"[[:space:]]*,?[[:space:]]*$ ]]; then - _pep_name="${BASH_REMATCH[1]}" - _pep_extras="${BASH_REMATCH[2]}" - _pep_version_spec="${BASH_REMATCH[3]}" - _pep_marker="${BASH_REMATCH[4]}" - # Validate: non-empty version_spec must start with a PEP 508 operator. - # Bare versions (e.g., "foo 1.0") are malformed PEP 508 and must not parse. - trimmed="${_pep_version_spec#"${_pep_version_spec%%[![:space:]]*}"}" - if [ -n "$trimmed" ]; then - case "$trimmed" in - \^*|\~=*|\~*|\>=*|==*|\<=*|\<*|\>*|!=*) ;; - *) return 1 ;; - esac - fi - return 0 - fi - return 1 - } - - # parse_poetry_keyval "$value" — for "spec" form. Sets _poetry_keyval_version. - parse_poetry_keyval() { - local value="$1" - _poetry_keyval_version="" - if [[ "$value" =~ ^[[:space:]]*\"([^\"]*)\"[[:space:]]*$ ]]; then - _poetry_keyval_version="${BASH_REMATCH[1]}" - return 0 - fi - return 1 - } - - # parse_poetry_inline "$value" — for { version = "spec", ... } form. - # Sets _poetry_inline_version and _poetry_inline_skeleton (version VALUE - # substituted, surrounding whitespace preserved). Requires `version` to be - # preceded by start-of-string, comma, or whitespace so `subversion` does - # not match (Bug 2). Preserves original whitespace around `=` so - # reformatting that changes spacing is caught (Bug 4). - parse_poetry_inline() { - local value="$1" inner - _poetry_inline_version=""; _poetry_inline_skeleton="" - if [[ "$value" =~ ^[[:space:]]*\{(.*)\}[[:space:]]*$ ]]; then - inner="${BASH_REMATCH[1]}" - if [[ "$inner" =~ (^|[,[:space:]])version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then - _poetry_inline_version="${BASH_REMATCH[2]}" - _poetry_inline_skeleton=$(printf '%s' "$inner" | sed -E 's/(^|[,[:space:]])(version[[:space:]]*=[[:space:]]*)"[^"]*"/\1\2"__VER__"/') - return 0 - fi - fi - return 1 - } - - # extract_operator "$spec" — returns the operator portion on stdout. - # "^", "~=", "~", ">=", "==" → printed verbatim; bare version → empty string. - # Unsupported operators (<, <=, >, !=) → return 1 (caller treats as disqualify). - extract_operator() { - local spec="$1" trimmed - trimmed="${spec#"${spec%%[![:space:]]*}"}" - case "$trimmed" in - \^*) printf '%s' '^' ;; - \~=*) printf '%s' '~=' ;; - \~*) printf '%s' '~' ;; - \>=*) printf '%s' '>=' ;; - ==*) printf '%s' '==' ;; - \<=*|\<*|\>*|!=*) return 1 ;; - *) printf '%s' '' ;; - esac - return 0 - } - - emit_bump() { - local name="$1" plus_spec="$2" minus_spec="$3" target plus_op minus_op - if [ "$plus_spec" = "$minus_spec" ]; then verdict="disqualified"; return; fi - plus_op=$(extract_operator "$plus_spec") || { verdict="disqualified"; return; } - minus_op=$(extract_operator "$minus_spec") || { verdict="disqualified"; return; } - if [ "$plus_op" != "$minus_op" ]; then verdict="disqualified"; return; fi - target=$(extract_target_version "$plus_spec") || { verdict="disqualified"; return; } - extract_target_version "$minus_spec" >/dev/null || { verdict="disqualified"; return; } - file_rows="${file_rows}${name} ${target} pypi"$'\n' - } - - clear_pending() { - pending_kind="" - pending_name="" - pending_skeleton="" - pending_minus_version="" - } - - flush_pending_as_disqualified() { - if [ -n "$pending_kind" ]; then - verdict="disqualified" - clear_pending - fi - } - - flush_file() { - flush_pending_as_disqualified - if [ -z "$current_basename" ] || [ "$current_basename" != "pyproject.toml" ]; then - return - fi - if [ "$verdict" = "clean" ]; then - while IFS= read -r row; do - [ -z "$row" ] && continue - local name="${row%% *}" - local key="pypi:$name" - case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac - seen="${seen}${key}"$'\n' - out_deps_rows+=("$row") - done <<< "$file_rows" - out_cleared_paths+=("$current_path") - fi - } - - reset_file_state() { - current_table="" - current_key="" - verdict="clean" - file_rows=$'\n' - clear_pending - } - - # --- Main parse loop --- - while IFS= read -r line; do - line="${line%$'\r'}" - - # File boundary. - if [[ "$line" =~ ^diff[[:space:]]--git[[:space:]]a/([^[:space:]]+)[[:space:]]b/([^[:space:]]+) ]]; then - flush_file - current_path="${BASH_REMATCH[2]}" - current_basename="${current_path##*/}" - reset_file_state - continue - fi - - [[ "$line" == +++* ]] && continue - [[ "$line" == ---* ]] && continue - - if [[ "$line" =~ ^@@ ]]; then - flush_pending_as_disqualified - current_table="" - current_key="" - continue - fi - - [ "$current_basename" != "pyproject.toml" ] && continue - [ "$verdict" = "disqualified" ] && continue - - prefix="${line:0:1}" - content="${line:1}" - - # ---------------- Centralized pending consumption ---------------- - # If pending is set, this line MUST be the matching + (same kind/name/skeleton) - # or the file is disqualified. No other line type may consume or pass through - # pending state. - if [ -n "$pending_kind" ]; then - consumed=false - if [ "$prefix" = "+" ]; then - case "$pending_kind" in - pep508) - if parse_pep508_entry "$content"; then - plus_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" - if [ "$_pep_name" = "$pending_name" ] && [ "$plus_skeleton" = "$pending_skeleton" ]; then - emit_bump "$_pep_name" "$_pep_version_spec" "$pending_minus_version" - consumed=true - fi - fi - ;; - poetry_keyval) - if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then - plus_key="${BASH_REMATCH[1]}" - plus_value="${BASH_REMATCH[2]}" - if [ "$plus_key" = "$pending_name" ] && parse_poetry_keyval "$plus_value"; then - emit_bump "$plus_key" "$_poetry_keyval_version" "$pending_minus_version" - consumed=true - fi - fi - ;; - poetry_inline) - if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then - plus_key="${BASH_REMATCH[1]}" - plus_value="${BASH_REMATCH[2]}" - if [ "$plus_key" = "$pending_name" ] && parse_poetry_inline "$plus_value"; then - if [ "$_poetry_inline_skeleton" = "$pending_skeleton" ]; then - emit_bump "$plus_key" "$_poetry_inline_version" "$pending_minus_version" - consumed=true - fi - fi - fi - ;; - esac - fi - clear_pending - if [ "$consumed" = "false" ]; then - verdict="disqualified" - fi - continue - fi - # ---------------- End pending consumption ---------------- - - # Comment / whitespace allowance anywhere (§3.4 rule 2). - if [[ "$content" =~ ^[[:space:]]*# ]]; then continue; fi - if [[ "$content" =~ ^[[:space:]]*$ ]]; then continue; fi - - # Table header detection. - if [[ "$content" =~ ^[[:space:]]*\[([^]]+)\] ]]; then - header="${BASH_REMATCH[1]}" - case "$header" in - project) current_table="project_other"; current_key="" ;; - project.optional-dependencies) current_table="project_optional_deps"; current_key="" ;; - dependency-groups) current_table="dependency_groups"; current_key="" ;; - tool.uv) current_table="tool_uv"; current_key="" ;; - tool.poetry.dependencies) current_table="poetry_main"; current_key="" ;; - tool.poetry.dev-dependencies) current_table="poetry_dev"; current_key="" ;; - build-system) current_table="build_system"; current_key="" ;; - *) - case "$header" in - tool.poetry.group.*.dependencies) current_table="poetry_group"; current_key="" ;; - *) current_table="other"; current_key="" ;; - esac - ;; - esac - # Changed table header = structural change → disqualify. - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - continue - fi - - # Key = ... line — per-table routing. - if [[ "$content" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_-]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then - key="${BASH_REMATCH[1]}" - value="${BASH_REMATCH[2]}" - case "$current_table" in - project_other) - if [ "$key" = "dependencies" ]; then - # `dependencies = [` on +/- = structural change. - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi - current_key="dependencies" - else - # Any other [project] key on +/- (name, version, description, ...) disqualifies. - current_key="" - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - fi - ;; - project_optional_deps|dependency_groups) - # Adding/renaming an extras key or dep group is structural. - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi - current_key="$key" - ;; - tool_uv) - case "$key" in - constraint-dependencies|override-dependencies) - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; continue; fi - current_key="$key" - ;; - *) - current_key="" - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - ;; - esac - ;; - poetry_main|poetry_group|poetry_dev) - if [ "$key" = "python" ]; then - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - continue - fi - # String form? Try first. - if parse_poetry_keyval "$value"; then - if [ "$prefix" = "-" ]; then - pending_kind="poetry_keyval" - pending_name="$key" - pending_skeleton="" - pending_minus_version="$_poetry_keyval_version" - continue - fi - if [ "$prefix" = "+" ]; then - # Unmatched + (no preceding -) → new-dep addition → disqualify. - verdict="disqualified" - continue - fi - # Context line: nothing. - continue - fi - # Inline-table form? - if parse_poetry_inline "$value"; then - if [ "$prefix" = "-" ]; then - pending_kind="poetry_inline" - pending_name="$key" - pending_skeleton="$_poetry_inline_skeleton" - pending_minus_version="$_poetry_inline_version" - continue - fi - if [ "$prefix" = "+" ]; then - verdict="disqualified" - continue - fi - continue - fi - # Unrecognized poetry value form on a changed line → disqualify - # (git URL, path, editable, etc.). - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - ;; - build_system|other) - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - ;; - esac - continue - fi - - # PEP 508 string-in-array entry. - if parse_pep508_entry "$content"; then - # Positively-established array context required (Blocker 3). - in_array=false - case "$current_table" in - project_other) - [ "$current_key" = "dependencies" ] && in_array=true - ;; - project_optional_deps|dependency_groups) - [ -n "$current_key" ] && in_array=true - ;; - tool_uv) - case "$current_key" in - constraint-dependencies|override-dependencies) in_array=true ;; - esac - ;; - esac - if [ "$in_array" = "false" ]; then - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - continue - fi - if [ "$prefix" = "-" ]; then - pending_kind="pep508" - pending_name="$_pep_name" - pending_skeleton="${_pep_name}${_pep_extras}__VER__${_pep_marker}" - pending_minus_version="$_pep_version_spec" - continue - fi - if [ "$prefix" = "+" ]; then - # Unmatched + (no preceding -) → new-dep addition → disqualify. - verdict="disqualified" - continue - fi - continue - fi - - # Closing `]` of an array. On +/- this is reformatting/structural → disqualify. - # On a context line, the array we were tracking has closed → reset current_key - # so subsequent PEP 508-shaped lines do not inherit dependency-array context - # (any later entries need fresh context to re-establish in_array). - if [[ "$content" =~ ^[[:space:]]*\] ]]; then - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - current_key="" - continue - fi - - # Anything else changed in pyproject.toml → disqualify. - if [ "$prefix" = "+" ] || [ "$prefix" = "-" ]; then verdict="disqualified"; fi - done <<< "$input" - - flush_file - - if [ "$MODE" = "deps" ]; then - if [ ${#out_deps_rows[@]} -gt 0 ]; then - printf '%s\n' "${out_deps_rows[@]}" | sort -t$'\t' -k1,1 -u - fi - else - if [ ${#out_cleared_paths[@]} -gt 0 ]; then - printf '%s\n' "${out_cleared_paths[@]}" | sort -u - fi - fi - ) - # --- END inline:scripts/pyproject-bump-extract.sh --- - - # --- BEGIN inline:scripts/pr-body-to-deps.sh --- - pr_body_to_deps() ( - # pr-body-to-deps.sh — extract Dependabot dep bumps from a PR body, emit TSV. - # Defense-in-depth fallback for when extract-deps.sh returns zero rows. - # - # Arg: — 'pypi' or 'actions'; emitted verbatim in TSV col 3. - # Input: PR body text on stdin. - # Output: \t\t sorted, deduplicated. - # Exit: 0 on success (including zero rows), 2 if ecosystem arg invalid. - - set -euo pipefail - - ecosystem="${1:-}" - case "$ecosystem" in - pypi|actions) ;; - *) echo "pr-body-to-deps.sh: ecosystem must be 'pypi' or 'actions'" >&2; exit 2 ;; - esac - - input=$(cat) - [ -z "$input" ] && exit 0 - - seen=$'\n' - rows=() - VER='[0-9][0-9A-Za-z.+!\-]*' - - # Regex patterns stored in variables — bash 3.2 on macOS cannot parse certain - # metacharacters (like `)`) inline within [[ =~ ]] conditionals. - # Pattern A — Bumps [name](url) from X to Y. The URL is consumed explicitly - # via \([^)]+\) rather than a character class, because earlier attempts using - # [^f]* silently dropped any bump whose URL contained `f` (e.g. `ruff`). - re_a="^Bumps[[:space:]]\[([^]]+)\]\([^)]+\)[[:space:]]+from[[:space:]]+${VER}[[:space:]]+to[[:space:]]+(${VER})" - # Pattern B — Updates `name` from X to Y (anchored at column 0 to avoid - # matching blockquote/release-notes content which is typically indented) - re_b="^Updates[[:space:]]\`([^\`]+)\`[[:space:]]+from[[:space:]]+${VER}[[:space:]]+to[[:space:]]+(${VER})" - # Pattern C — | [name](url) | `fromVer` | `toVer` | - re_c="^\|[[:space:]]+\[([^]]+)\]\([^)]+\)[[:space:]]+\|[[:space:]]+\`${VER}\`[[:space:]]+\|[[:space:]]+\`(${VER})\`[[:space:]]+\|" - - while IFS= read -r line; do - line="${line%$'\r'}" - name="" - version="" - - if [[ "$line" =~ $re_a ]]; then - name="${BASH_REMATCH[1]}"; version="${BASH_REMATCH[2]}" - elif [[ "$line" =~ $re_b ]]; then - name="${BASH_REMATCH[1]}"; version="${BASH_REMATCH[2]}" - elif [[ "$line" =~ $re_c ]]; then - name="${BASH_REMATCH[1]}"; version="${BASH_REMATCH[2]}" - fi - - [ -z "$name" ] && continue - - # Strip trailing sentence punctuation from the version (e.g. "2.13.0." - # in Pattern A's "Bumps ... from X to Y." lines). - version="${version%.}" - - key="$ecosystem:$name" - case "$seen" in *$'\n'"$key"$'\n'*) continue ;; esac - seen="${seen}${key}"$'\n' - rows+=("$name"$'\t'"$version"$'\t'"$ecosystem") - done <<< "$input" - - if [ ${#rows[@]} -gt 0 ]; then - printf '%s\n' "${rows[@]}" | sort -t$'\t' -k1,1 - fi - ) - # --- END inline:scripts/pr-body-to-deps.sh --- - - # --- BEGIN inline:scripts/check-release-age.sh --- - check_release_age() ( - # check-release-age.sh — read dep TSV on stdin, emit verdict TSV on stdout - # - # Schema: - # in: \t\t - # out: \t\t\t\t\t\t - # - # 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 — 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 — 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 — print "\t" 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 - ) - # --- END inline:scripts/check-release-age.sh --- - - DIFF=$(gh pr diff "$PR_NUMBER" --repo "$GH_REPO") - - DEPS="" - SCAN_RESULTS="" - GHSA_TOTAL=0 - OSV_TOTAL=0 - HAS_ERROR="" - declare -A ACTION_VERSIONS PY_VERSIONS - FILTERED_RESULTS="" - FILTERED_TOTAL=0 - - # Fetch PR body for Dependabot version fallback - PR_BODY=$(gh pr view "$PR_NUMBER" --repo "$GH_REPO" --json body --jq '.body // ""') - - # --- Extract dependencies via standalone script (Task 8) --- - # Layer 1: extract deps from supported file shapes. - EXTRACTED=$(echo "$DIFF" | extract_deps) - PYPROJECT_DEPS=$(printf '%s' "$DIFF" | pyproject_bump_extract --mode=deps 2>/dev/null || true) - DEPS_TSV=$(printf '%s\n%s\n' "$EXTRACTED" "$PYPROJECT_DEPS" | sed '/^$/d' | sort -u) - - # --- Compute touched paths and unsupported subset ONCE, up front. --- - # Layer 2: identify unsupported dependency-relevant paths. - # CLEARED_PYPROJECT is computed in the same hoist so Layer 2 and Layer 3 - # both see a consistent view of which paths the diff-aware helper proved - # are bump-only (fix for issue #66 — uv/poetry Dependabot PRs). - TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true) - BASE_UNSUPPORTED=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true) - CLEARED_PYPROJECT=$(printf '%s' "$DIFF" | pyproject_bump_extract --mode=cleared-paths 2>/dev/null || true) - - # Subtract cleared paths from BOTH the unsupported set and the touched - # set used by Layer 3's zero-row guard. A pyproject.toml cleared with - # zero extracted deps (e.g., comment-only churn) represents proof that - # the scan was complete — there is nothing to scan. Without this - # subtraction, a comment-only pyproject PR would trip the zero-row guard. - UNSUPPORTED_PATHS="$BASE_UNSUPPORTED" - EFFECTIVE_TOUCHED="$TOUCHED_PATHS" - if [ -n "$(printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d')" ]; then - cleared_file=$(mktemp "${RUNNER_TEMP:-/tmp}/cleared-pyproject-paths.XXXXXX") - printf '%s\n' "$CLEARED_PYPROJECT" | sed '/^$/d' | sort -u > "$cleared_file" - UNSUPPORTED_PATHS=$(printf '%s\n' "$BASE_UNSUPPORTED" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) - EFFECTIVE_TOUCHED=$(printf '%s\n' "$TOUCHED_PATHS" | sed '/^$/d' | grep -vFxf "$cleared_file" || true) - rm -f "$cleared_file" - fi - - # --- Layer 2: PR-body fallback (defense in depth for issue #52) --- - # pyproject.toml and Pipfile remain pypi-routable for PR-body fallback. - # Layer 3 still fires on Pipfile and on any pyproject.toml whose hunks - # pyproject_bump_extract did not clear; cleared pyproject.toml paths - # are subtracted from UNSUPPORTED_PATHS in the composition block above. - if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then - ECO_HINT="" - if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then - ECO_HINT="pypi" - elif echo "$TOUCHED_PATHS" | grep -qE '\.github/workflows/.*\.ya?ml$'; then - ECO_HINT="actions" - fi - if [ -n "$ECO_HINT" ]; then - DEPS_TSV=$(echo "$PR_BODY" | pr_body_to_deps "$ECO_HINT" 2>/dev/null || true) - [ -n "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && \ - echo "Recovered $(echo "$DEPS_TSV" | wc -l | tr -d ' ') dep(s) via PR-body fallback (${ECO_HINT})" - fi - fi - - # --- Layer 3: Fail-loud guard (fixes for issues #52 and #62) --- - # Two composed conditions: - # (a) Unsupported paths touched: scan cannot be proven complete (issue #62). - # (b) All touched paths supported but extraction yielded zero rows: parser - # miss on a supported file (issue #52). - # Unsupported takes message precedence — it's the more specific diagnosis. - GUARD_TRIGGERED=false - if [ -n "$UNSUPPORTED_PATHS" ]; then - GUARD_TRIGGERED=true - UNSUPPORTED_LIST=$(echo "$UNSUPPORTED_PATHS" | paste -sd, -) - EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the cooldown/safety gate cannot scan these." - elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$EFFECTIVE_TOUCHED" ]; then - GUARD_TRIGGERED=true - TOUCHED_LIST=$(echo "$EFFECTIVE_TOUCHED" | paste -sd, -) - EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge." - fi - - # Populate ACTIONS, PY_DEPS, ACTION_VERSIONS, PY_VERSIONS from DEPS_TSV - # so the existing advisory-scan loops below can consume them unchanged. - ACTIONS="" - PY_DEPS="" - while IFS=$'\t' read -r _name _ver _eco; do - [ -z "$_name" ] && continue - case "$_eco" in - actions) - ACTIONS="$(printf '%s\n%s' "$ACTIONS" "$_name")" - ACTION_VERSIONS["$_name"]="$_ver" - ;; - pypi) - PY_DEPS="$(printf '%s\n%s' "$PY_DEPS" "$_name")" - PY_VERSIONS["$_name"]="$_ver" - ;; - esac - done <<< "$DEPS_TSV" - ACTIONS=$(echo "$ACTIONS" | sed '/^$/d' | sort -u) - PY_DEPS=$(echo "$PY_DEPS" | sed '/^$/d' | sort -u) - - # PR-body version fallback for deps without inline version (preserved from v2.0.1) - for ACTION in $ACTIONS; do - [ -z "$ACTION" ] && continue - if [ -z "${ACTION_VERSIONS[$ACTION]:-}" ]; then - _dep_ver=$(echo "$PR_BODY" | grep -F "[$ACTION]" | grep -oE 'to [0-9][0-9.]*' | head -1 | sed 's/^to //' || true) - ACTION_VERSIONS["$ACTION"]="$_dep_ver" - fi - done - for PKG in $PY_DEPS; do - [ -z "$PKG" ] && continue - if [ -z "${PY_VERSIONS[$PKG]:-}" ]; then - _dep_ver=$(echo "$PR_BODY" | grep -Fi "[$PKG]" | grep -oE 'to [0-9][0-9.]*' | head -1 | sed 's/^to //' || true) - PY_VERSIONS["$PKG"]="$_dep_ver" - fi - done - - # Sanity-check: count UNIQUE action names in the diff vs extracted actions. - # Filters ./ (local) and docker:// actions because the extractor at - # scripts/extract-deps.sh:51-52 intentionally skips them — counting them - # here would produce a false-positive extraction-mismatch warning on any - # PR adding a local or docker action reference. - # (Task 13c — fixes regression introduced by Task 13b) - EXPECTED_ACTIONS=$(echo "$DIFF" \ - | grep -oE '^\+[[:space:]]+(-[[:space:]]+)?uses:[[:space:]]+[^[:space:]@]+' \ - | sed -E 's/^\+[[:space:]]+(-[[:space:]]+)?uses:[[:space:]]+//' \ - | grep -vE '^(\./|docker://)' \ - | sort -u | wc -l | tr -d ' ') - EXTRACTED_ACTIONS=$(echo "$ACTIONS" | sed '/^$/d' | wc -l | tr -d ' ') - # Do not overwrite a guard-set EXTRACTION_WARNING. - if [ -z "${EXTRACTION_WARNING:-}" ]; then - EXTRACTION_WARNING="" - if [ "$EXPECTED_ACTIONS" != "$EXTRACTED_ACTIONS" ]; then - EXTRACTION_WARNING="⚠️ Extraction mismatch: diff contains ${EXPECTED_ACTIONS} unique action(s), extractor returned ${EXTRACTED_ACTIONS}. This may indicate a silent parsing failure. Please report." - fi - fi - - # --- Check release age via standalone script (Task 9 — phase 2) --- - COOLDOWN_FAILURES=0 - AGE_TSV="" - if [ "$COOLDOWN_DAYS" -gt 0 ]; then - # check_release_age must be invoked via command substitution so - # bash's parent -e is dropped — this preserves per-row error - # tolerance (404s, parse failures, etc. emit error verdicts - # instead of killing the step). Do not convert to a direct call. - AGE_TSV=$(echo "$DEPS_TSV" | check_release_age) - COOLDOWN_FAILURES=$(echo "$AGE_TSV" | awk -F'\t' '$6=="fail" || $6=="error"' | wc -l | tr -d ' ') - fi - - for ACTION in $ACTIONS; do - [ -z "$ACTION" ] && continue - _ver="${ACTION_VERSIONS[$ACTION]}" - if [ -n "$_ver" ]; then - DEPS="${DEPS}\`${ACTION}\` (v${_ver}), " - else - DEPS="${DEPS}\`${ACTION}\`, " - fi - - GHSA_RESULT=$(gh api graphql -f query=' - query($name: String!) { - securityVulnerabilities( - ecosystem: ACTIONS - package: $name - first: 10 - ) { - nodes { - advisory { - ghsaId - severity - summary - } - vulnerableVersionRange - firstPatchedVersion { - identifier - } - } - } - } - ' -f "name=${ACTION}" 2>&1) || { - HAS_ERROR="true" - SCAN_RESULTS="${SCAN_RESULTS}| (GHSA query failed for \`${ACTION}\`) | - | - | GHSA |"$'\n' - GHSA_RESULT="" - } - - if [ -n "$GHSA_RESULT" ] && echo "$GHSA_RESULT" | jq -e '.errors' > /dev/null 2>&1; then - HAS_ERROR="true" - GHSA_ERR=$(echo "$GHSA_RESULT" | jq -r '.errors[0].message // "unknown error"') - SCAN_RESULTS="${SCAN_RESULTS}| (GHSA error for \`${ACTION}\`: ${GHSA_ERR}) | - | - | GHSA |"$'\n' - GHSA_RESULT="" - fi - - if [ -n "$GHSA_RESULT" ]; then - _target_ver="${ACTION_VERSIONS[$ACTION]}" - - if [ -n "$_target_ver" ]; then - while IFS=$'\t' read -r _g_id _g_sev _g_sum _g_patched; do - [ -z "$_g_id" ] && continue - if [ -n "$_g_patched" ]; then - _lowest=$(printf '%s\n' "$_g_patched" "$_target_ver" | sort -V | head -1) - if [ "$_lowest" = "$_g_patched" ]; then - FILTERED_RESULTS="${FILTERED_RESULTS}| ${_g_id} | ${_g_sev} | ${_g_sum} | GHSA | ${_g_patched} |"$'\n' - FILTERED_TOTAL=$((FILTERED_TOTAL + 1)) - continue - fi - fi - SCAN_RESULTS="${SCAN_RESULTS}| ${_g_id} | ${_g_sev} | ${_g_sum} | GHSA |"$'\n' - GHSA_TOTAL=$((GHSA_TOTAL + 1)) - done <<< "$(echo "$GHSA_RESULT" | jq -r ' - .data.securityVulnerabilities.nodes[] | - [.advisory.ghsaId, .advisory.severity, .advisory.summary, ((.firstPatchedVersion.identifier // "") | ltrimstr("v"))] | - @tsv - ' 2>/dev/null || true)" - else - GHSA_VULNS=$(echo "$GHSA_RESULT" | jq -r ' - .data.securityVulnerabilities.nodes[] | - "| \(.advisory.ghsaId) | \(.advisory.severity) | \(.advisory.summary) | GHSA |" - ' 2>/dev/null || true) - - if [ -n "$GHSA_VULNS" ]; then - GHSA_COUNT=$(echo "$GHSA_VULNS" | wc -l | tr -d ' ') - GHSA_TOTAL=$((GHSA_TOTAL + GHSA_COUNT)) - SCAN_RESULTS="${SCAN_RESULTS}${GHSA_VULNS}"$'\n' - fi - fi - fi - - _target_ver="${ACTION_VERSIONS[$ACTION]}" - if [ -n "$_target_ver" ]; then - OSV_BODY=$(jq -n --arg name "$ACTION" --arg eco "GitHub Actions" --arg ver "$_target_ver" \ - '{"package":{"name":$name,"ecosystem":$eco},"version":$ver}') - else - OSV_BODY=$(jq -n --arg name "$ACTION" --arg eco "GitHub Actions" \ - '{"package":{"name":$name,"ecosystem":$eco}}') - fi - OSV_RESULT=$(curl -sf --max-time 30 \ - -X POST "https://api.osv.dev/v1/query" \ - -H "Content-Type: application/json" \ - -d "$OSV_BODY" \ - 2>&1) || { - HAS_ERROR="true" - SCAN_RESULTS="${SCAN_RESULTS}| (OSV query failed for \`${ACTION}\`) | - | - | OSV |"$'\n' - OSV_RESULT="" - } - - if [ -n "$OSV_RESULT" ]; then - OSV_VULNS=$(echo "$OSV_RESULT" | jq -r ' - .vulns[]? | - "| \(.id) | \(.database_specific.severity // "UNKNOWN") | \(.summary // .details[:80]) | OSV |" - ' 2>/dev/null || true) - - if [ -n "$OSV_VULNS" ]; then - OSV_COUNT=$(echo "$OSV_VULNS" | wc -l | tr -d ' ') - OSV_TOTAL=$((OSV_TOTAL + OSV_COUNT)) - SCAN_RESULTS="${SCAN_RESULTS}${OSV_VULNS}"$'\n' - fi - fi - done - - for PKG in $PY_DEPS; do - [ -z "$PKG" ] && continue - _ver="${PY_VERSIONS[$PKG]}" - if [ -n "$_ver" ]; then - DEPS="${DEPS}\`${PKG}\` (v${_ver}), " - else - DEPS="${DEPS}\`${PKG}\`, " - fi - - GHSA_RESULT=$(gh api graphql -f query=' - query($name: String!) { - securityVulnerabilities( - ecosystem: PIP - package: $name - first: 10 - ) { - nodes { - advisory { - ghsaId - severity - summary - } - vulnerableVersionRange - firstPatchedVersion { - identifier - } - } - } - } - ' -f "name=${PKG}" 2>&1) || { - HAS_ERROR="true" - SCAN_RESULTS="${SCAN_RESULTS}| (GHSA query failed for \`${PKG}\`) | - | - | GHSA |"$'\n' - GHSA_RESULT="" - } - - if [ -n "$GHSA_RESULT" ] && echo "$GHSA_RESULT" | jq -e '.errors' > /dev/null 2>&1; then - HAS_ERROR="true" - GHSA_ERR=$(echo "$GHSA_RESULT" | jq -r '.errors[0].message // "unknown error"') - SCAN_RESULTS="${SCAN_RESULTS}| (GHSA error for \`${PKG}\`: ${GHSA_ERR}) | - | - | GHSA |"$'\n' - GHSA_RESULT="" - fi - - if [ -n "$GHSA_RESULT" ]; then - _target_ver="${PY_VERSIONS[$PKG]}" - - if [ -n "$_target_ver" ]; then - while IFS=$'\t' read -r _g_id _g_sev _g_sum _g_patched; do - [ -z "$_g_id" ] && continue - if [ -n "$_g_patched" ]; then - _lowest=$(printf '%s\n' "$_g_patched" "$_target_ver" | sort -V | head -1) - if [ "$_lowest" = "$_g_patched" ]; then - FILTERED_RESULTS="${FILTERED_RESULTS}| ${_g_id} | ${_g_sev} | ${_g_sum} | GHSA | ${_g_patched} |"$'\n' - FILTERED_TOTAL=$((FILTERED_TOTAL + 1)) - continue - fi - fi - SCAN_RESULTS="${SCAN_RESULTS}| ${_g_id} | ${_g_sev} | ${_g_sum} | GHSA |"$'\n' - GHSA_TOTAL=$((GHSA_TOTAL + 1)) - done <<< "$(echo "$GHSA_RESULT" | jq -r ' - .data.securityVulnerabilities.nodes[] | - [.advisory.ghsaId, .advisory.severity, .advisory.summary, ((.firstPatchedVersion.identifier // "") | ltrimstr("v"))] | - @tsv - ' 2>/dev/null || true)" - else - GHSA_VULNS=$(echo "$GHSA_RESULT" | jq -r ' - .data.securityVulnerabilities.nodes[] | - "| \(.advisory.ghsaId) | \(.advisory.severity) | \(.advisory.summary) | GHSA |" - ' 2>/dev/null || true) - - if [ -n "$GHSA_VULNS" ]; then - GHSA_COUNT=$(echo "$GHSA_VULNS" | wc -l | tr -d ' ') - GHSA_TOTAL=$((GHSA_TOTAL + GHSA_COUNT)) - SCAN_RESULTS="${SCAN_RESULTS}${GHSA_VULNS}"$'\n' - fi - fi - fi - - _target_ver="${PY_VERSIONS[$PKG]}" - if [ -n "$_target_ver" ]; then - OSV_BODY=$(jq -n --arg name "$PKG" --arg eco "PyPI" --arg ver "$_target_ver" \ - '{"package":{"name":$name,"ecosystem":$eco},"version":$ver}') - else - OSV_BODY=$(jq -n --arg name "$PKG" --arg eco "PyPI" \ - '{"package":{"name":$name,"ecosystem":$eco}}') - fi - OSV_RESULT=$(curl -sf --max-time 30 \ - -X POST "https://api.osv.dev/v1/query" \ - -H "Content-Type: application/json" \ - -d "$OSV_BODY" \ - 2>&1) || { - HAS_ERROR="true" - SCAN_RESULTS="${SCAN_RESULTS}| (OSV query failed for \`${PKG}\`) | - | - | OSV |"$'\n' - OSV_RESULT="" - } - - if [ -n "$OSV_RESULT" ]; then - OSV_VULNS=$(echo "$OSV_RESULT" | jq -r ' - .vulns[]? | - "| \(.id) | \(.database_specific.severity // "UNKNOWN") | \(.summary // .details[:80]) | OSV |" - ' 2>/dev/null || true) - - if [ -n "$OSV_VULNS" ]; then - OSV_COUNT=$(echo "$OSV_VULNS" | wc -l | tr -d ' ') - OSV_TOTAL=$((OSV_TOTAL + OSV_COUNT)) - SCAN_RESULTS="${SCAN_RESULTS}${OSV_VULNS}"$'\n' - fi - fi - done - - # --- OpenSSF Scorecard (GitHub Actions only) --- - SCORECARD_TABLE="" - if [[ "$ENABLE_SCORECARD" == "true" ]]; then - for ACTION in $ACTIONS; do - [ -z "$ACTION" ] && continue - SC_RESULT=$(curl -sf --max-time 30 \ - "https://api.securityscorecards.dev/projects/github.com/${ACTION}" \ - 2>/dev/null) || SC_RESULT="" - - if [ -z "$SC_RESULT" ]; then - SCORECARD_TABLE="${SCORECARD_TABLE}| \`${ACTION}\` | Not available | - |"$'\n' - else - SC_SCORE=$(echo "$SC_RESULT" | jq -r '.score // "N/A"') - SC_URL="https://scorecard.dev/viewer/?uri=github.com/${ACTION}" - SCORECARD_TABLE="${SCORECARD_TABLE}| \`${ACTION}\` | ${SC_SCORE}/10 | [View](${SC_URL}) |"$'\n' - fi - done - fi - - SCORECARD_SECTION="" - if [ -n "$SCORECARD_TABLE" ]; then - SC_HDR="| Action | Score | Details |" - SC_SEP="|--------|-------|---------|" - SCORECARD_SECTION="$(printf '\n### Project Health (OpenSSF Scorecard)\n\n%s\n%s\n%s' \ - "$SC_HDR" "$SC_SEP" "$SCORECARD_TABLE")" - fi - - # Strip trailing comma-space from DEPS - DEPS=$(echo "$DEPS" | sed 's/, $//') - - if [ -z "$DEPS" ]; then - DEPS="(no dependencies extracted from diff)" - fi - - # --- Build comment body --- - TOTAL=$((GHSA_TOTAL + OSV_TOTAL)) - - # --- Label reconciliation (Task 13b — authoritative on clean runs, additive-only on HAS_ERROR paths) --- - LABEL_COLOR_security_review_needed="B60205" - LABEL_DESC_security_review_needed="Dependency scan found advisories — manual review required" - LABEL_COLOR_cooldown_pending="FBCA04" - LABEL_DESC_cooldown_pending="One or more target versions are within the configured cooldown window" - - reconcile_label() { - local label="$1" - local should_be_present="$2" - local color="$3" - local desc="$4" - local current has_it - current=$(gh pr view "$PR_NUMBER" --repo "$GH_REPO" --json labels --jq '[.labels[].name]') - has_it=$(echo "$current" | jq -e --arg l "$label" 'contains([$l])' >/dev/null && echo true || echo false) - - if [ "$should_be_present" = "true" ] && [ "$has_it" = "false" ]; then - gh label create "$label" --repo "$GH_REPO" --color "$color" --description "$desc" --force >/dev/null 2>&1 || true - gh pr edit "$PR_NUMBER" --repo "$GH_REPO" --add-label "$label" || true - echo "Applied label: $label" - elif [ "$should_be_present" = "false" ] && [ "$has_it" = "true" ] && [ -z "${HAS_ERROR:-}" ]; then - gh pr edit "$PR_NUMBER" --repo "$GH_REPO" --remove-label "$label" || true - echo "Removed stale label: $label" - elif [ "$should_be_present" = "false" ] && [ "$has_it" = "true" ]; then - echo "Preserving label $label: scan had API errors, verdict is unreliable" - fi - } - - _needs_security=$([ "$TOTAL" -gt 0 ] && echo true || echo false) - _needs_cooldown=$([ "$COOLDOWN_FAILURES" -gt 0 ] && echo true || echo false) - reconcile_label "security-review-needed" "$_needs_security" "$LABEL_COLOR_security_review_needed" "$LABEL_DESC_security_review_needed" - reconcile_label "cooldown-pending" "$_needs_cooldown" "$LABEL_COLOR_cooldown_pending" "$LABEL_DESC_cooldown_pending" - - # Guard-triggered runs must not compose a clean-scan narrative — the - # gate state machine below flips status to `error`, and the comment - # needs to agree (rather than claim "No known exploits" + auto-merge - # enabled on a run we refused to scan). The EXTRACTION_WARNING_SECTION - # prepended later (~line 960) still carries the detailed path list. - if [ "$GUARD_TRIGGERED" = "true" ] && [ "$TOTAL" -eq 0 ]; then - # Extraction failed AND no advisories from any subset that did get scanned. - # With the issue #62 fix the guard can ALSO fire on a partial-extraction PR - # where DEPS_TSV is non-empty and scans found advisories — that case falls - # through to the TOTAL>0 branch so the actual advisory IDs are preserved. - RESULTS_HEADER="Dependency extraction failed — manual review required." - TABLE_HDR="| Source | Vulnerabilities |" - TABLE_SEP="|--------|----------------|" - ROW1="| GitHub Advisory (GHSA) | (not scanned) |" - ROW2="| OSV.dev | (not scanned) |" - RESULTS_TABLE="$(printf '%s\n%s\n%s\n%s' "$TABLE_HDR" "$TABLE_SEP" "$ROW1" "$ROW2")" - RESULTS_FOOTER="> Auto-merge not enabled — parser could not determine target versions from diff or PR body." - elif [ "$TOTAL" -gt 0 ]; then - RESULTS_HEADER="**${TOTAL} advisory/ies found** affecting target versions — review before merging." - TABLE_HDR="| ID | Severity | Summary | Source |" - TABLE_SEP="|----|----------|---------|--------|" - RESULTS_TABLE="$(printf '%s\n%s\n%s' "$TABLE_HDR" "$TABLE_SEP" "$SCAN_RESULTS")" - if [ "$GUARD_TRIGGERED" = "true" ]; then - # Partial extraction: scans of the supported subset DID surface advisories. - # Cross-reference so the partial-coverage signal isn't lost. - RESULTS_FOOTER="> Review the advisories above before deciding to merge."$'\n'"> Note: part of the diff could not be scanned (parser does not support all touched files). Manual review required." - else - RESULTS_FOOTER="> Review the advisories above before deciding to merge." - fi - elif [ -n "$HAS_ERROR" ]; then - gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ - -f state=error \ - -f context="dependency-cooldown / gate" \ - -f description="Scan failed due to API errors — re-run or push to retry." - exit 0 - else - RESULTS_HEADER="No known exploits found affecting the target versions." - TABLE_HDR="| Source | Vulnerabilities |" - TABLE_SEP="|--------|----------------|" - ROW1="| GitHub Advisory (GHSA) | 0 affecting target |" - ROW2="| OSV.dev | 0 affecting target |" - RESULTS_TABLE="$(printf '%s\n%s\n%s\n%s' "$TABLE_HDR" "$TABLE_SEP" "$ROW1" "$ROW2")" - if [[ "$AUTO_MERGE" == "true" ]]; then - RESULTS_FOOTER="> Auto-merge has been enabled. This PR will merge once all status checks pass." - else - RESULTS_FOOTER="> This PR is now eligible for manual review and merge." - fi - fi - - # Build collapsed historical section (non-blocking, for transparency) - HISTORICAL_SECTION="" - if [ "$FILTERED_TOTAL" -gt 0 ]; then - HIST_HDR="| ID | Severity | Summary | Source | Patched In |" - HIST_SEP="|----|----------|---------|--------|------------|" - HISTORICAL_SECTION="$(printf '\n\n
\n%d historical advisory/ies (patched at or before target version — not blocking)\n\n%s\n%s\n%s
' \ - "$FILTERED_TOTAL" "$HIST_HDR" "$HIST_SEP" "$FILTERED_RESULTS")" - fi - - # --- Release Age section (Task 12 — fixes #25 UX) --- - RELEASE_AGE_SECTION="" - if [ -n "$AGE_TSV" ]; then - AGE_TABLE_HDR="| Package | Version | Published | Age | Status |" - AGE_TABLE_SEP="|---------|---------|-----------|-----|--------|" - AGE_ROWS="" - MAX_FAIL_EPOCH=0 - while IFS=$'\t' read -r _name _ver _eco _iso _days _verdict _reason; do - [ -z "$_name" ] && continue - case "$_verdict" in - pass) _status="✅ passed" ;; - fail) if [ -n "$_reason" ]; then _status="❌ blocked (${_reason})"; else _status="❌ blocked"; fi ;; - error) _status="⚠️ error (${_reason})" ;; - *) _status="?" ;; - esac - if [ "$_iso" = "-" ]; then - _published_short="-" - else - _published_short="${_iso%T*}" - fi - AGE_ROWS="${AGE_ROWS}| \`${_name}\` | v${_ver} | ${_published_short} | ${_days}d | ${_status} |"$'\n' - - # Track max fail epoch for "earliest unblock" footer - if [ "$_verdict" = "fail" ] && [ "$_iso" != "-" ]; then - _pub_epoch=$(date -u -d "$_iso" +%s 2>/dev/null \ - || date -u -d "${_iso}Z" +%s 2>/dev/null \ - || date -u -jf '%Y-%m-%dT%H:%M:%SZ' "$_iso" +%s 2>/dev/null \ - || date -u -jf '%Y-%m-%dT%H:%M:%S' "${_iso%Z}" +%s 2>/dev/null \ - || echo 0) - if [ "$_pub_epoch" -gt "$MAX_FAIL_EPOCH" ]; then - MAX_FAIL_EPOCH="$_pub_epoch" - fi - fi - done <<< "$AGE_TSV" - - AGE_FOOTER="" - if [ "$COOLDOWN_FAILURES" -gt 0 ] && [ "$MAX_FAIL_EPOCH" -gt 0 ]; then - _unblock_epoch=$(( MAX_FAIL_EPOCH + COOLDOWN_DAYS * 86400 )) - _unblock_iso=$(date -u -d "@$_unblock_epoch" '+%Y-%m-%d' 2>/dev/null \ - || date -u -r "$_unblock_epoch" '+%Y-%m-%d' 2>/dev/null \ - || echo "unknown") - AGE_FOOTER="$(printf '\n> %d package(s) within cooldown window. Earliest unblock: %s.' "$COOLDOWN_FAILURES" "$_unblock_iso")" - fi - - RELEASE_AGE_SECTION="$(printf '\n### Release Age (cooldown: %s days)\n\n%s\n%s\n%s%s' \ - "$COOLDOWN_DAYS" "$AGE_TABLE_HDR" "$AGE_TABLE_SEP" "$AGE_ROWS" "$AGE_FOOTER")" - fi - - # --- Extraction warning section (Task 12 — surfaces #27 silent drops) --- - EXTRACTION_WARNING_SECTION="" - if [ -n "$EXTRACTION_WARNING" ]; then - EXTRACTION_WARNING_SECTION="$(printf '\n> %s\n' "$EXTRACTION_WARNING")" - fi - - COMMENT_BODY="$(printf '%s%s\n\n%s\n\n%s\n\n%s\n\n%s\n\n%s%s%s%s' \ - "## Dependency Security Scan" \ - "$EXTRACTION_WARNING_SECTION" \ - "**Packages scanned:** ${DEPS}" \ - "### Results" \ - "$RESULTS_HEADER" \ - "$RESULTS_TABLE" \ - "$RESULTS_FOOTER" \ - "$HISTORICAL_SECTION" \ - "$RELEASE_AGE_SECTION" \ - "$SCORECARD_SECTION")" - - # --- Comment management: update-or-create with change detection --- - COMMENTS_JSON=$(gh api "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" --paginate) - EXISTING_COMMENT_ID=$(echo "$COMMENTS_JSON" | jq -r " - [.[] | select( - (.body | contains(\"" - TIMESTAMP="Last scanned: $(date -u +'%Y-%m-%d %H:%M') UTC" - DIVIDER="---" - COMMENT_BODY="$(printf '%s\n%s\n\n%s\n_%s_' \ - "$SCAN_MARKER" \ - "$COMMENT_BODY" \ - "$DIVIDER" \ - "$TIMESTAMP")" - - if [ -n "$EXISTING_COMMENT_ID" ]; then - gh api "repos/${GH_REPO}/issues/comments/${EXISTING_COMMENT_ID}" \ - -X PATCH \ - -f body="$COMMENT_BODY" - echo "Updated existing scan comment (ID: ${EXISTING_COMMENT_ID})" - else - gh pr comment "$PR_NUMBER" \ - --repo "$GH_REPO" \ - --body "$COMMENT_BODY" - echo "Created new scan comment" - fi - - if [ -n "$EXISTING_COMMENT_ID" ] && [ "$PREV_IDS" != "$CURRENT_IDS" ]; then - CHANGE_NOTE="Advisory status changed since last scan — review updated findings above." - gh pr comment "$PR_NUMBER" \ - --repo "$GH_REPO" \ - --body "$CHANGE_NOTE" - echo "Posted change notification" - fi - - # --- Gate state machine (Task 11 — combines advisory + cooldown verdicts) --- - if [ "$GUARD_TRIGGERED" = "true" ]; then - # Fail-loud guard: parser could not extract deps from a lockfile-touching - # diff, and the PR-body fallback also produced nothing. Refuse to issue - # a green gate — manual review required (fix for issue #52). - GATE_STATE="error" - STATUS_DESC="Could not extract dependencies from diff. Manual review required." - AUTO_MERGE_OK=false - elif [ "$TOTAL" -gt 0 ]; then - # Advisory failure wins — strictest signal (existing v2.0.1 semantic: gate=success with "review needed") - GATE_STATE="success" - STATUS_DESC="${TOTAL} advisory/ies found (version-filtered). Manual review required." - AUTO_MERGE_OK=false - elif [ "$COOLDOWN_FAILURES" -gt 0 ]; then - if [ "$FAIL_ON_COOLDOWN" = "true" ]; then - GATE_STATE="failure" - else - GATE_STATE="pending" - fi - STATUS_DESC="${COOLDOWN_FAILURES} package(s) within ${COOLDOWN_DAYS}-day cooldown window. Waiting for age." - AUTO_MERGE_OK=false - else - GATE_STATE="success" - STATUS_DESC="Clean scan (version-filtered, cooldown ≥ ${COOLDOWN_DAYS}d). Ready for merge." - AUTO_MERGE_OK=true - fi - - # --- Conditional auto-merge --- - if [ "$AUTO_MERGE" = "true" ] && [ "$AUTO_MERGE_OK" = "true" ]; then - if gh pr merge --auto --squash "$PR_NUMBER" --repo "$GH_REPO"; then - STATUS_DESC="${STATUS_DESC} Auto-merge enabled." - echo "Auto-merge enabled for PR #${PR_NUMBER}" - else - STATUS_DESC="${STATUS_DESC} Auto-merge unavailable — merge manually." - echo "WARNING: gh pr merge failed for PR #${PR_NUMBER}" - fi - fi - - # --- Set final status --- - gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ - -f state="$GATE_STATE" \ - -f context="dependency-cooldown / gate" \ - -f description="$STATUS_DESC" diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index 8f0e82f..0cd4c5f 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -67,7 +67,7 @@ jobs: ENABLE_SCORECARD: ${{ inputs.enable_scorecard }} AUTO_MERGE: ${{ inputs.auto_merge }} # COOLDOWN_DAYS is intentionally named for the inlined check-release-age.sh, - # which is shared with dependency-cooldown.yml and requires that env name. + # which requires that env name unchanged from its standalone-script form. # MINIMUM_RELEASE_AGE_DAYS feeds safety-verdict.sh and human-readable strings. COOLDOWN_DAYS: ${{ inputs.minimum_release_age_days }} MINIMUM_RELEASE_AGE_DAYS: ${{ inputs.minimum_release_age_days }} @@ -95,7 +95,7 @@ jobs: # pypi-shape lockfiles only. Cargo.lock / package-lock.json would need # downstream ecosystem support (registry clients, OSV enums) before rows - # could be scanned — the fail-loud guard in dependency-cooldown.yml covers + # could be scanned — the fail-loud guard in dependency-safety.yml covers # unhandled lockfiles. filename_to_ecosystem() { case "$1" in @@ -210,7 +210,7 @@ jobs: diff_touches_lockfile() ( # diff-touches-lockfile.sh — detect dependency-relevant file edits in a # unified diff (lockfiles, manifests, and GitHub Actions workflow YAMLs). - # Used by dependency-cooldown.yml's fail-loud guard to refuse green gates + # Used by dependency-safety.yml's fail-loud guard to refuse green gates # when the dep extractor returns zero rows on a diff that touches files # which *should* have produced deps. # @@ -326,8 +326,8 @@ jobs: pyproject_bump_extract() ( # pyproject-bump-extract.sh — diff-aware extractor for pyproject.toml bump-only edits. # - # Owns all pyproject.toml diff semantics for the dependency-cooldown/safety - # workflows. Recognizes the narrow set of bump shapes Dependabot emits for + # Owns all pyproject.toml diff semantics for the dependency-safety + # workflow. Recognizes the narrow set of bump shapes Dependabot emits for # uv/poetry ecosystems and emits either extracted dep rows (mode=deps) or # the paths it proved are bump-only (mode=cleared-paths). Files with any # unparseable changed line (build-system edits, new-dep additions, marker @@ -1232,7 +1232,7 @@ jobs: if [ -n "$UNSUPPORTED_PATHS" ]; then GUARD_TRIGGERED=true UNSUPPORTED_LIST=$(echo "$UNSUPPORTED_PATHS" | paste -sd, -) - EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the cooldown/safety gate cannot scan these." + EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the dependency-safety gate cannot scan these." elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$EFFECTIVE_TOUCHED" ]; then GUARD_TRIGGERED=true TOUCHED_LIST=$(echo "$EFFECTIVE_TOUCHED" | paste -sd, -) @@ -1656,8 +1656,7 @@ jobs: # The reconcile_label function preserves stale labels when HAS_ERROR is set # (conservative-preserve rule, spec §6.2). # - # This workflow MUST NOT touch `cooldown-pending` — that label is owned - # exclusively by the legacy dependency-cooldown.yml during Phase 2 migration. + # This workflow does not manage the retired `cooldown-pending` label. reconcile_label "security-review-needed" "$HAS_SECURITY_REVIEW" "$LABEL_COLOR_security_review_needed" "$LABEL_DESC_security_review_needed" reconcile_label "dependency-age-violation" "$HAS_AGE_VIOLATION" "$LABEL_COLOR_dependency_age_violation" "$LABEL_DESC_dependency_age_violation" reconcile_label "dependency-safety-error" "$HAS_SAFETY_ERROR" "$LABEL_COLOR_dependency_safety_error" "$LABEL_DESC_dependency_safety_error" diff --git a/README.md b/README.md index 4c3d4ae..6efbd36 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ concurrency: jobs: safety: - uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v2 + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v3 secrets: inherit with: auto_merge: true @@ -86,10 +86,9 @@ jobs: `contents: write` is only required when `auto_merge: true`; otherwise `contents: read` is sufficient. -> **Note:** `@v2` resolves to `dependency-safety.yml` only after a release of -> this repo that contains it. Before that, pin to the explicit tag where the -> workflow first shipped (`@vX.Y.Z`). Releases in this repo are dispatched -> manually — see [Versioning](#versioning). +> **Note:** `@v3` is the current floating major. `@v2` continues to work at +> the last cooldown-bearing release (frozen, no further updates). Releases +> in this repo are dispatched manually — see [Versioning](#versioning). ## Inputs @@ -168,27 +167,41 @@ Labels: Reconciliation is authoritative when the scan succeeds. On the `error` path, labels are preserved (not removed) since the verdict is unreliable. -## Migration From Legacy Cooldown +## v2 → v3 migration -If you're migrating from `dependency-cooldown.yml`: +`v3.0.0` removed the deprecated `dependency-cooldown.yml` and +`cooldown-rescan.yml` workflows. If your repo still references them via +`@v2`, the pin continues to work against the frozen v2 line; to move to +`v3`, follow these steps: 1. **Add native cooldown** to `.github/dependabot.yml` (`cooldown.default-days: 5` or higher). -2. **Replace the caller `uses:`** line: + +2. **Replace the caller `uses:` line:** ```diff - uses: j7an/shared-workflows/.github/workflows/dependency-cooldown.yml@v2 - + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v2 + + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v3 ``` + 3. **Rename the input** `cooldown_days` → `minimum_release_age_days`. -4. **Drop `fail_on_cooldown`** — replaced by `fail_on_age_violation` with different semantics (failure-on-violation, not pending-on-violation). -5. **Remove any caller workflow** that uses `cooldown-rescan.yml` (no rescan companion under the new model — the verifier is single-shot per PR event). -6. **Update branch protection / rulesets.** The commit-status context changes from `dependency-cooldown / gate` to `dependency-safety / gate`. Required-status-check rules on the old context will wait forever once you cut over. In Settings → Branches (or Rules), remove `dependency-cooldown / gate` from the required checks list and add `dependency-safety / gate` in its place. The two contexts can coexist briefly if you keep both workflows running during a Phase 2 transition. -7. **Optional:** add `rebase-strategy: disabled` to your `dependabot.yml` ecosystem block. This avoids `@dependabot rebase` pulling in newer versions that have not yet aged through native cooldown. -After migration, the `cooldown-pending` label (managed by the legacy workflow) will become stale on the PR; the new workflow does not touch it, so remove it manually if desired. +4. **Drop `fail_on_cooldown`** — replaced by `fail_on_age_violation` with + different semantics (failure-on-violation, not pending-on-violation). + +5. **Remove any caller workflow** that uses `cooldown-rescan.yml`. No rescan + companion under `dependency-safety.yml` — the verifier is single-shot per + PR event. -## Legacy Workflows +6. **Update branch protection / rulesets.** The commit-status context changes + from `dependency-cooldown / gate` to `dependency-safety / gate`. Required- + status-check rules on the old context will wait forever once you cut over. -`dependency-cooldown.yml` and `cooldown-rescan.yml` implement the pre-2026 workflow-owned waiting model: the workflow itself held PRs in `pending` state for the cooldown window, and a separate rescan workflow swept stale PRs on a schedule. These remain available during the sibling-migration window and will be removed in a future major release once known consumers have moved to `dependency-safety.yml`. See [Migration From Legacy Cooldown](#migration-from-legacy-cooldown) above. +7. **Clean up stale labels.** Any `cooldown-pending` label managed by the + legacy workflow lingers until manually removed; `dependency-safety.yml` + does not touch it. + +8. **Optional:** add `rebase-strategy: disabled` to your `dependabot.yml` + ecosystem block — avoids `@dependabot rebase` pulling in newer versions + that have not yet aged through native cooldown. ## Security Analysis (Zizmor) @@ -320,12 +333,12 @@ permissions: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v2 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 secrets: RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} publish: needs: tag - uses: j7an/shared-workflows/.github/workflows/release.yml@v2 + uses: j7an/shared-workflows/.github/workflows/release.yml@v3 with: tag: ${{ needs.tag.outputs.tag }} ``` @@ -352,14 +365,14 @@ permissions: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v2 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 with: bump: ${{ inputs.bump }} secrets: RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} publish: needs: tag - uses: j7an/shared-workflows/.github/workflows/release.yml@v2 + uses: j7an/shared-workflows/.github/workflows/release.yml@v3 with: tag: ${{ needs.tag.outputs.tag }} ``` @@ -377,6 +390,16 @@ If your repo has version strings in committed JSON files (e.g. `server.json`, `p The `environment: release` + `if: github.ref == 'refs/heads/main'` gate inside `tag-release.yml` runs in **your repo's** security context — `shared-workflows` cannot unilaterally enforce it across consumers. If you skip step 1, you lose the environment-side branch policy and secret protection; the in-file `if:` check still blocks non-`main` refs, but the extra GitHub-side gate is gone. -### On the `@v2` pin +### On the `@v3` pin + +`@v3` is the floating major tag for the current `v3.x.y` line. It always +points at the latest `v3.x.y` release because `release.yml` force-updates +floating majors on every publish. Pinning to `@v3` means you get all +non-breaking updates within v3 automatically. Pin to `@v3.0` for patch-only +updates, or `@v3.0.0` for an immutable freeze — see the [Versioning](#versioning) +section above. -`@v2` is the floating major tag for the current `v2.x.y` line. It always points at the latest `v2.x.y` release because `release.yml` force-updates floating majors on every publish. Pinning to `@v2` means you get all non-breaking updates automatically. Pin to `@v2.1` for patch-only updates, or `@v2.1.0` for an immutable freeze — see the [Versioning](#versioning) section above. +`@v2` is the frozen historical line, pinned at the last cooldown-bearing +release. It continues to work for `tag-release.yml`, `publish-pypi.yml`, and +`dependency-safety.yml`, but receives no further updates. Consumers on `@v2` +should plan migration to `@v3` (see [v2 → v3 migration](#v2--v3-migration)). diff --git a/scripts/check-inline-sync.sh b/scripts/check-inline-sync.sh index 5c9993c..c1dd5e9 100755 --- a/scripts/check-inline-sync.sh +++ b/scripts/check-inline-sync.sh @@ -11,11 +11,6 @@ set -euo pipefail # Each entry: ":" INLINE_PAIRS=( - ".github/workflows/dependency-cooldown.yml:scripts/extract-deps.sh" - ".github/workflows/dependency-cooldown.yml:scripts/check-release-age.sh" - ".github/workflows/dependency-cooldown.yml:scripts/diff-touches-lockfile.sh" - ".github/workflows/dependency-cooldown.yml:scripts/pr-body-to-deps.sh" - ".github/workflows/dependency-cooldown.yml:scripts/classify-touched-paths.sh" ".github/workflows/dependency-safety.yml:scripts/extract-deps.sh" ".github/workflows/dependency-safety.yml:scripts/check-release-age.sh" ".github/workflows/dependency-safety.yml:scripts/diff-touches-lockfile.sh" @@ -23,7 +18,6 @@ INLINE_PAIRS=( ".github/workflows/dependency-safety.yml:scripts/safety-verdict.sh" ".github/workflows/dependency-safety.yml:scripts/classify-touched-paths.sh" ".github/workflows/tag-release.yml:scripts/bump-version-files.sh" - ".github/workflows/dependency-cooldown.yml:scripts/pyproject-bump-extract.sh" ".github/workflows/dependency-safety.yml:scripts/pyproject-bump-extract.sh" ) diff --git a/scripts/diff-touches-lockfile.sh b/scripts/diff-touches-lockfile.sh index fe23717..bb33078 100755 --- a/scripts/diff-touches-lockfile.sh +++ b/scripts/diff-touches-lockfile.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # diff-touches-lockfile.sh — detect dependency-relevant file edits in a # unified diff (lockfiles, manifests, and GitHub Actions workflow YAMLs). -# Used by dependency-cooldown.yml's fail-loud guard to refuse green gates +# Used by dependency-safety.yml's fail-loud guard to refuse green gates # when the dep extractor returns zero rows on a diff that touches files # which *should* have produced deps. # diff --git a/scripts/extract-deps.sh b/scripts/extract-deps.sh index a205ae3..0248ba1 100755 --- a/scripts/extract-deps.sh +++ b/scripts/extract-deps.sh @@ -19,7 +19,7 @@ set -euo pipefail # pypi-shape lockfiles only. Cargo.lock / package-lock.json would need # downstream ecosystem support (registry clients, OSV enums) before rows -# could be scanned — the fail-loud guard in dependency-cooldown.yml covers +# could be scanned — the fail-loud guard in dependency-safety.yml covers # unhandled lockfiles. filename_to_ecosystem() { case "$1" in diff --git a/scripts/pyproject-bump-extract.sh b/scripts/pyproject-bump-extract.sh index 7b10f70..71c9e81 100755 --- a/scripts/pyproject-bump-extract.sh +++ b/scripts/pyproject-bump-extract.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # pyproject-bump-extract.sh — diff-aware extractor for pyproject.toml bump-only edits. # -# Owns all pyproject.toml diff semantics for the dependency-cooldown/safety -# workflows. Recognizes the narrow set of bump shapes Dependabot emits for +# Owns all pyproject.toml diff semantics for the dependency-safety +# workflow. Recognizes the narrow set of bump shapes Dependabot emits for # uv/poetry ecosystems and emits either extracted dep rows (mode=deps) or # the paths it proved are bump-only (mode=cleared-paths). Files with any # unparseable changed line (build-system edits, new-dep additions, marker diff --git a/tests/classify-touched-paths.bats b/tests/classify-touched-paths.bats index 2eea265..e2adf37 100644 --- a/tests/classify-touched-paths.bats +++ b/tests/classify-touched-paths.bats @@ -55,7 +55,7 @@ } # Path-only classifier marks pyproject.toml unsupported. The -# dependency-cooldown/safety workflows may clear it via +# dependency-safety workflow may clear it via # scripts/pyproject-bump-extract.sh after diff inspection (issue #66); # this test exercises the classifier in isolation. @test "pyproject.toml — unsupported (path-only)" { diff --git a/tests/guard-runtime.bats b/tests/guard-runtime.bats index f890313..fbef4b5 100644 --- a/tests/guard-runtime.bats +++ b/tests/guard-runtime.bats @@ -21,17 +21,6 @@ extract_guard_block() { | sed -E 's/^ //' } -@test "cooldown: issue #62 case (DEPS_TSV non-empty + UNSUPPORTED non-empty) — guard fires" { - block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) - DEPS_TSV=$'mypy\t1.20.1\tpypi' - TOUCHED_PATHS=$'package-lock.json\nuv.lock' - EFFECTIVE_TOUCHED="$TOUCHED_PATHS" - UNSUPPORTED_PATHS="package-lock.json" - eval "$block" - [ "$GUARD_TRIGGERED" = "true" ] - [[ "$EXTRACTION_WARNING" == *"does not support"* ]] -} - @test "safety: issue #62 case (DEPS_TSV non-empty + UNSUPPORTED non-empty) — guard fires" { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV=$'mypy\t1.20.1\tpypi' @@ -43,17 +32,6 @@ extract_guard_block() { [[ "$EXTRACTION_WARNING" == *"does not support"* ]] } -@test "cooldown: issue #52 case (DEPS_TSV empty + TOUCHED non-empty + UNSUPPORTED empty) — guard fires" { - block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) - DEPS_TSV="" - TOUCHED_PATHS="uv.lock" - EFFECTIVE_TOUCHED="$TOUCHED_PATHS" - UNSUPPORTED_PATHS="" - eval "$block" - [ "$GUARD_TRIGGERED" = "true" ] - [[ "$EXTRACTION_WARNING" == *"Parser could not extract"* ]] -} - @test "safety: issue #52 case (DEPS_TSV empty + TOUCHED non-empty + UNSUPPORTED empty) — guard fires" { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV="" @@ -65,16 +43,6 @@ extract_guard_block() { [[ "$EXTRACTION_WARNING" == *"Parser could not extract"* ]] } -@test "cooldown: clean supported case (DEPS_TSV non-empty + UNSUPPORTED empty) — guard does NOT fire" { - block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) - DEPS_TSV=$'requests\t2.32.0\tpypi' - TOUCHED_PATHS="requirements.txt" - EFFECTIVE_TOUCHED="$TOUCHED_PATHS" - UNSUPPORTED_PATHS="" - eval "$block" - [ "$GUARD_TRIGGERED" = "false" ] -} - @test "safety: clean supported case (DEPS_TSV non-empty + UNSUPPORTED empty) — guard does NOT fire" { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV=$'requests\t2.32.0\tpypi' @@ -85,16 +53,6 @@ extract_guard_block() { [ "$GUARD_TRIGGERED" = "false" ] } -@test "cooldown: no touched paths (empty diff) — guard does NOT fire" { - block=$(extract_guard_block .github/workflows/dependency-cooldown.yml) - DEPS_TSV="" - TOUCHED_PATHS="" - EFFECTIVE_TOUCHED="" - UNSUPPORTED_PATHS="" - eval "$block" - [ "$GUARD_TRIGGERED" = "false" ] -} - @test "safety: no touched paths (empty diff) — guard does NOT fire" { block=$(extract_guard_block .github/workflows/dependency-safety.yml) DEPS_TSV="" diff --git a/tests/guard-shape.bats b/tests/guard-shape.bats index f79e4a4..166d2e2 100644 --- a/tests/guard-shape.bats +++ b/tests/guard-shape.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# guard-shape.bats — static assertions that both safety workflows compose the +# guard-shape.bats — static assertions that the safety workflow composes the # Layer 3 guard in the required order: UNSUPPORTED_PATHS branch first # (issue #62), then the zero-rows fallback branch (issue #52). # @@ -7,19 +7,9 @@ # unit-testable; this guard prevents accidental ordering regressions. WORKFLOWS=( - ".github/workflows/dependency-cooldown.yml" ".github/workflows/dependency-safety.yml" ) -@test "dependency-cooldown.yml: TOUCHED_PATHS/UNSUPPORTED_PATHS hoisted above Layer 2" { - yaml=".github/workflows/dependency-cooldown.yml" - hoist_line=$(grep -n "UNSUPPORTED_PATHS=\$(printf" "$yaml" | head -1 | cut -d: -f1) - layer2_line=$(grep -n "Layer 2: PR-body fallback" "$yaml" | head -1 | cut -d: -f1) - [ -n "$hoist_line" ] - [ -n "$layer2_line" ] - [ "$hoist_line" -lt "$layer2_line" ] -} - @test "dependency-safety.yml: TOUCHED_PATHS/UNSUPPORTED_PATHS hoisted above Layer 2" { yaml=".github/workflows/dependency-safety.yml" hoist_line=$(grep -n "UNSUPPORTED_PATHS=\$(printf" "$yaml" | head -1 | cut -d: -f1) @@ -29,15 +19,6 @@ WORKFLOWS=( [ "$hoist_line" -lt "$layer2_line" ] } -@test "dependency-cooldown.yml: Layer 3 guard checks UNSUPPORTED_PATHS before zero-rows elif" { - yaml=".github/workflows/dependency-cooldown.yml" - unsupported_line=$(grep -n 'if \[ -n "\$UNSUPPORTED_PATHS" \]; then' "$yaml" | head -1 | cut -d: -f1) - elif_line=$(grep -n 'elif \[ -z "\$(echo "\$DEPS_TSV" | sed' "$yaml" | head -1 | cut -d: -f1) - [ -n "$unsupported_line" ] - [ -n "$elif_line" ] - [ "$unsupported_line" -lt "$elif_line" ] -} - @test "dependency-safety.yml: Layer 3 guard checks UNSUPPORTED_PATHS before zero-rows elif" { yaml=".github/workflows/dependency-safety.yml" unsupported_line=$(grep -n 'if \[ -n "\$UNSUPPORTED_PATHS" \]; then' "$yaml" | head -1 | cut -d: -f1)