From 98e4d6b30f8136efba3c78837f060d2ad4ad8734 Mon Sep 17 00:00:00 2001 From: j7an Date: Wed, 10 Jun 2026 22:56:25 -0700 Subject: [PATCH 1/5] feat(safety)!: replace fail_on_age_violation with release_age_policy in verdict layer BREAKING CHANGE: post-PR release-age verification is now opt-in via release_age_policy (default "off") and auto_merge defaults to true; the fail_on_age_violation input is removed. Refs #85 --- scripts/safety-verdict.sh | 51 +++++++++++++--- tests/safety-verdict.bats | 118 +++++++++++++++++++++++++++++++++----- 2 files changed, 147 insertions(+), 22 deletions(-) diff --git a/scripts/safety-verdict.sh b/scripts/safety-verdict.sh index 00988e9..707b8a1 100755 --- a/scripts/safety-verdict.sh +++ b/scripts/safety-verdict.sh @@ -12,10 +12,14 @@ # AGE_VIOLATION_COUNT int >=0 # SCAN_ERROR_COUNT int >=0 (GHSA/OSV only — scorecard failures excluded) # ADVISORY_COUNT int >=0 (post version-aware filtering) -# FAIL_ON_AGE_VIOLATION true|false -# MINIMUM_RELEASE_AGE_DAYS int >=0 +# RELEASE_AGE_POLICY off|advisory|blocking +# MINIMUM_RELEASE_AGE_DAYS int >=0 (status text only; ignored when policy=off) # AUTO_MERGE true|false # +# Contract invariant: RELEASE_AGE_POLICY=off requires AGE_ERROR_COUNT=0 and +# AGE_VIOLATION_COUNT=0 — no age data may exist when no lookup ran. A +# violation indicates an orchestrator bug and fails closed (exit 2). +# # Output (stdout, single TSV line, six fields, no trailing newline beyond one): # gate_state\tauto_merge_ok\thas_safety_error\thas_age_violation\thas_security_review\tstatus_desc # @@ -51,8 +55,19 @@ require_nonneg_int() { esac } +require_policy() { + local val + val=${RELEASE_AGE_POLICY-__unset__} + case "$val" in + __unset__) die "missing required env: RELEASE_AGE_POLICY" ;; + off|advisory|blocking) ;; + false) die "RELEASE_AGE_POLICY must be off|advisory|blocking, got: false (unquoted YAML off parses as boolean false — quote it: \"off\")" ;; + *) die "RELEASE_AGE_POLICY must be off|advisory|blocking, got: $val" ;; + esac +} + require_bool GUARD_TRIGGERED -require_bool FAIL_ON_AGE_VIOLATION +require_policy require_bool AUTO_MERGE require_nonneg_int AGE_ERROR_COUNT require_nonneg_int AGE_VIOLATION_COUNT @@ -60,6 +75,16 @@ require_nonneg_int SCAN_ERROR_COUNT require_nonneg_int ADVISORY_COUNT require_nonneg_int MINIMUM_RELEASE_AGE_DAYS +# Contract invariant (see header): no age data may exist when policy is off. +if [ "$RELEASE_AGE_POLICY" = "off" ]; then + if [ "$AGE_VIOLATION_COUNT" -gt 0 ]; then + die "RELEASE_AGE_POLICY=off but AGE_VIOLATION_COUNT=$AGE_VIOLATION_COUNT (orchestrator bug)" + fi + if [ "$AGE_ERROR_COUNT" -gt 0 ]; then + die "RELEASE_AGE_POLICY=off but AGE_ERROR_COUNT=$AGE_ERROR_COUNT (orchestrator bug)" + fi +fi + error_total=$(( AGE_ERROR_COUNT + SCAN_ERROR_COUNT )) # --- gate_state (priority order) --- @@ -67,7 +92,7 @@ if [ "$GUARD_TRIGGERED" = "true" ]; then gate_state="error" elif [ "$error_total" -gt 0 ]; then gate_state="error" -elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$FAIL_ON_AGE_VIOLATION" = "true" ]; then +elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$RELEASE_AGE_POLICY" = "blocking" ]; then gate_state="failure" else gate_state="success" @@ -108,16 +133,24 @@ if [ "$GUARD_TRIGGERED" = "true" ]; then status_desc="Could not extract dependencies from diff. Manual review required." elif [ "$error_total" -gt 0 ]; then status_desc="Scan errors: ${AGE_ERROR_COUNT} age lookup(s), ${SCAN_ERROR_COUNT} advisory query/ies. Re-run or push to retry." -elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$FAIL_ON_AGE_VIOLATION" = "true" ]; then - status_desc="${AGE_VIOLATION_COUNT} package(s) younger than ${MINIMUM_RELEASE_AGE_DAYS}d cooldown — Dependabot native cooldown invariant violated." +elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$RELEASE_AGE_POLICY" = "blocking" ]; then + status_desc="${AGE_VIOLATION_COUNT} package(s) younger than ${MINIMUM_RELEASE_AGE_DAYS}d minimum release age (blocking policy)." elif [ "$ADVISORY_COUNT" -gt 0 ]; then status_desc="${ADVISORY_COUNT} advisory/ies found (version-filtered). Manual review required." elif [ "$AGE_VIOLATION_COUNT" -gt 0 ]; then - status_desc="${AGE_VIOLATION_COUNT} package(s) below ${MINIMUM_RELEASE_AGE_DAYS}d cooldown (advisory mode). Auto-merge suppressed." + status_desc="${AGE_VIOLATION_COUNT} package(s) below ${MINIMUM_RELEASE_AGE_DAYS}d minimum release age (advisory policy). Auto-merge suppressed." elif [ "$auto_merge_ok" = "true" ]; then - status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Auto-merge enabled." + if [ "$RELEASE_AGE_POLICY" = "off" ]; then + status_desc="Clean scan (no advisories). Auto-merge enabled." + else + status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Auto-merge enabled." + fi else - status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Ready for merge." + if [ "$RELEASE_AGE_POLICY" = "off" ]; then + status_desc="Clean scan (no advisories). Ready for merge." + else + status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Ready for merge." + fi fi printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ diff --git a/tests/safety-verdict.bats b/tests/safety-verdict.bats index 955647c..cc2dffe 100644 --- a/tests/safety-verdict.bats +++ b/tests/safety-verdict.bats @@ -2,6 +2,8 @@ # Helper: invoke safety-verdict.sh with full env, capture stdout (status). # Tests parse the single TSV line into named fields for assertions. +# Note: bats `run` merges stderr into $output — fail-closed tests assert +# diagnostics there. run_verdict() { run bash scripts/safety-verdict.sh } @@ -13,19 +15,21 @@ parse_tsv() { <<< "$output" } -# Default env: clean state. Individual tests override. +# Default env: clean state under the strictest policy (blocking), so the +# age-violation tests inherit v3-equivalent semantics. Individual tests +# override RELEASE_AGE_POLICY to advisory/off as needed. setup() { export GUARD_TRIGGERED=false export AGE_ERROR_COUNT=0 export AGE_VIOLATION_COUNT=0 export SCAN_ERROR_COUNT=0 export ADVISORY_COUNT=0 - export FAIL_ON_AGE_VIOLATION=true + export RELEASE_AGE_POLICY=blocking export MINIMUM_RELEASE_AGE_DAYS=5 export AUTO_MERGE=false } -@test "clean + AUTO_MERGE=true → success, auto_merge_ok=true" { +@test "clean + AUTO_MERGE=true (blocking) → success, auto_merge_ok=true, age text present" { export AUTO_MERGE=true run_verdict [ "$status" -eq 0 ] @@ -35,19 +39,50 @@ setup() { [ "$HAS_SAFETY_ERROR" = "false" ] [ "$HAS_AGE_VIOLATION" = "false" ] [ "$HAS_SECURITY_REVIEW" = "false" ] - [[ "$STATUS_DESC" == *"Auto-merge enabled"* ]] + [ "$STATUS_DESC" = "Clean scan (≥5d, no advisories). Auto-merge enabled." ] } -@test "clean + AUTO_MERGE=false → success, auto_merge_ok=false" { +@test "clean + AUTO_MERGE=false (blocking) → success, auto_merge_ok=false" { run_verdict [ "$status" -eq 0 ] parse_tsv [ "$GATE_STATE" = "success" ] [ "$AUTO_MERGE_OK" = "false" ] - [[ "$STATUS_DESC" == *"Ready for merge"* ]] + [ "$STATUS_DESC" = "Clean scan (≥5d, no advisories). Ready for merge." ] } -@test "advisory only → success, has_security_review=true, auto_merge_ok=false" { +@test "clean + policy=advisory → success, age text present" { + export RELEASE_AGE_POLICY=advisory + run_verdict + [ "$status" -eq 0 ] + parse_tsv + [ "$GATE_STATE" = "success" ] + [ "$AUTO_MERGE_OK" = "false" ] + [ "$STATUS_DESC" = "Clean scan (≥5d, no advisories). Ready for merge." ] +} + +@test "clean + policy=off + AUTO_MERGE=true → success, auto_merge_ok=true, no age text" { + export RELEASE_AGE_POLICY=off + export AUTO_MERGE=true + run_verdict + [ "$status" -eq 0 ] + parse_tsv + [ "$GATE_STATE" = "success" ] + [ "$AUTO_MERGE_OK" = "true" ] + [ "$STATUS_DESC" = "Clean scan (no advisories). Auto-merge enabled." ] +} + +@test "clean + policy=off + AUTO_MERGE=false → success, no age text" { + export RELEASE_AGE_POLICY=off + run_verdict + [ "$status" -eq 0 ] + parse_tsv + [ "$GATE_STATE" = "success" ] + [ "$AUTO_MERGE_OK" = "false" ] + [ "$STATUS_DESC" = "Clean scan (no advisories). Ready for merge." ] +} + +@test "advisory finding only → success, has_security_review=true, auto_merge_ok=false" { export ADVISORY_COUNT=2 export AUTO_MERGE=true run_verdict @@ -59,7 +94,7 @@ setup() { [[ "$STATUS_DESC" == *"2 advisory"* ]] } -@test "age violation + FAIL_ON_AGE_VIOLATION=true → failure" { +@test "age violation + policy=blocking → failure" { export AGE_VIOLATION_COUNT=1 run_verdict [ "$status" -eq 0 ] @@ -67,12 +102,12 @@ setup() { [ "$GATE_STATE" = "failure" ] [ "$AUTO_MERGE_OK" = "false" ] [ "$HAS_AGE_VIOLATION" = "true" ] - [[ "$STATUS_DESC" == *"younger than 5d"* ]] + [[ "$STATUS_DESC" == *"younger than 5d minimum release age (blocking policy)"* ]] } -@test "age violation + FAIL_ON_AGE_VIOLATION=false → success, label still applied, auto_merge_ok=false" { +@test "age violation + policy=advisory → success, label still applied, auto_merge_ok=false" { export AGE_VIOLATION_COUNT=3 - export FAIL_ON_AGE_VIOLATION=false + export RELEASE_AGE_POLICY=advisory export AUTO_MERGE=true run_verdict [ "$status" -eq 0 ] @@ -80,10 +115,11 @@ setup() { [ "$GATE_STATE" = "success" ] [ "$AUTO_MERGE_OK" = "false" ] [ "$HAS_AGE_VIOLATION" = "true" ] - [[ "$STATUS_DESC" == *"advisory mode"* ]] + [[ "$STATUS_DESC" == *"advisory policy"* ]] + [[ "$STATUS_DESC" == *"Auto-merge suppressed"* ]] } -@test "age lookup error → error, has_safety_error=true" { +@test "age lookup error + policy=blocking → error, has_safety_error=true" { export AGE_ERROR_COUNT=1 run_verdict [ "$status" -eq 0 ] @@ -94,6 +130,17 @@ setup() { [[ "$STATUS_DESC" == *"Scan errors"* ]] } +@test "age lookup error + policy=advisory → error (fail-closed even when non-blocking)" { + export AGE_ERROR_COUNT=1 + export RELEASE_AGE_POLICY=advisory + run_verdict + [ "$status" -eq 0 ] + parse_tsv + [ "$GATE_STATE" = "error" ] + [ "$AUTO_MERGE_OK" = "false" ] + [ "$HAS_SAFETY_ERROR" = "true" ] +} + @test "scan error (GHSA/OSV) → error, has_safety_error=true" { export SCAN_ERROR_COUNT=2 run_verdict @@ -103,6 +150,16 @@ setup() { [ "$HAS_SAFETY_ERROR" = "true" ] } +@test "scan error + policy=off → error (advisory-scan errors still fail closed)" { + export RELEASE_AGE_POLICY=off + export SCAN_ERROR_COUNT=1 + run_verdict + [ "$status" -eq 0 ] + parse_tsv + [ "$GATE_STATE" = "error" ] + [ "$HAS_SAFETY_ERROR" = "true" ] +} + @test "guard triggered → error, has_safety_error=true" { export GUARD_TRIGGERED=true run_verdict @@ -156,3 +213,38 @@ setup() { run_verdict [ "$status" -ne 0 ] } + +@test "RELEASE_AGE_POLICY unset → non-zero exit (fail-closed)" { + unset RELEASE_AGE_POLICY + run_verdict + [ "$status" -ne 0 ] +} + +@test "RELEASE_AGE_POLICY=bogus → non-zero exit (fail-closed)" { + export RELEASE_AGE_POLICY=bogus + run_verdict + [ "$status" -ne 0 ] +} + +@test "RELEASE_AGE_POLICY=false → non-zero exit with YAML quoting hint" { + export RELEASE_AGE_POLICY=false + run_verdict + [ "$status" -ne 0 ] + [[ "$output" == *"quote it"* ]] +} + +@test "policy=off + AGE_VIOLATION_COUNT>0 → non-zero exit (orchestrator-bug invariant)" { + export RELEASE_AGE_POLICY=off + export AGE_VIOLATION_COUNT=1 + run_verdict + [ "$status" -ne 0 ] + [[ "$output" == *"orchestrator bug"* ]] +} + +@test "policy=off + AGE_ERROR_COUNT>0 → non-zero exit (orchestrator-bug invariant)" { + export RELEASE_AGE_POLICY=off + export AGE_ERROR_COUNT=2 + run_verdict + [ "$status" -ne 0 ] + [[ "$output" == *"orchestrator bug"* ]] +} From fed38d8ffce0f4cf78e073db49074d0f03b77d8e Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 11 Jun 2026 07:02:25 -0700 Subject: [PATCH 2/5] feat(safety)!: opt-in release-age policy and default-on auto-merge in dependency-safety.yml BREAKING CHANGE: post-PR release-age verification is now opt-in via release_age_policy (default "off") and auto_merge defaults to true; the fail_on_age_violation input is removed. Refs #85 --- .github/workflows/dependency-safety.yml | 109 ++++++++++++++++-------- tests/safety-workflow-contract.bats | 44 ++++++++++ 2 files changed, 119 insertions(+), 34 deletions(-) create mode 100644 tests/safety-workflow-contract.bats diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index add749e..131d329 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -9,16 +9,16 @@ on: description: "Include OpenSSF Scorecard in scan results" auto_merge: type: boolean - default: false - description: "Auto-merge clean PRs after scan completes" + default: true + description: "Auto-merge clean PRs after scan completes. Default on; set false for manual merges (required if the caller grants only contents: read)." + release_age_policy: + type: string + default: "off" + description: "Release-age verification policy: off (default - no age lookup), advisory (label + comment on young targets, gate stays green, auto-merge suppressed), blocking (gate fails on young targets). Quote off in caller YAML." minimum_release_age_days: type: number default: 5 - description: "Floor for target-version release age. Verified at scan time; should match cooldown.default-days in your dependabot.yml." - fail_on_age_violation: - type: boolean - default: true - description: "If true, age violation sets gate to failure. If false, gate is success with a dependency-age-violation label only. Auto-merge is suppressed in either case." + description: "Threshold used when release_age_policy is advisory or blocking; ignored when off. Should match cooldown.default-days in your dependabot.yml." jobs: scan: @@ -90,7 +90,7 @@ jobs: # 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 }} - FAIL_ON_AGE_VIOLATION: ${{ inputs.fail_on_age_violation }} + RELEASE_AGE_POLICY: ${{ inputs.release_age_policy }} run: | # --- BEGIN inline:scripts/extract-deps.sh --- extract_deps() ( @@ -1062,10 +1062,14 @@ jobs: # AGE_VIOLATION_COUNT int >=0 # SCAN_ERROR_COUNT int >=0 (GHSA/OSV only — scorecard failures excluded) # ADVISORY_COUNT int >=0 (post version-aware filtering) - # FAIL_ON_AGE_VIOLATION true|false - # MINIMUM_RELEASE_AGE_DAYS int >=0 + # RELEASE_AGE_POLICY off|advisory|blocking + # MINIMUM_RELEASE_AGE_DAYS int >=0 (status text only; ignored when policy=off) # AUTO_MERGE true|false # + # Contract invariant: RELEASE_AGE_POLICY=off requires AGE_ERROR_COUNT=0 and + # AGE_VIOLATION_COUNT=0 — no age data may exist when no lookup ran. A + # violation indicates an orchestrator bug and fails closed (exit 2). + # # Output (stdout, single TSV line, six fields, no trailing newline beyond one): # gate_state\tauto_merge_ok\thas_safety_error\thas_age_violation\thas_security_review\tstatus_desc # @@ -1101,8 +1105,19 @@ jobs: esac } + require_policy() { + local val + val=${RELEASE_AGE_POLICY-__unset__} + case "$val" in + __unset__) die "missing required env: RELEASE_AGE_POLICY" ;; + off|advisory|blocking) ;; + false) die "RELEASE_AGE_POLICY must be off|advisory|blocking, got: false (unquoted YAML off parses as boolean false — quote it: \"off\")" ;; + *) die "RELEASE_AGE_POLICY must be off|advisory|blocking, got: $val" ;; + esac + } + require_bool GUARD_TRIGGERED - require_bool FAIL_ON_AGE_VIOLATION + require_policy require_bool AUTO_MERGE require_nonneg_int AGE_ERROR_COUNT require_nonneg_int AGE_VIOLATION_COUNT @@ -1110,6 +1125,16 @@ jobs: require_nonneg_int ADVISORY_COUNT require_nonneg_int MINIMUM_RELEASE_AGE_DAYS + # Contract invariant (see header): no age data may exist when policy is off. + if [ "$RELEASE_AGE_POLICY" = "off" ]; then + if [ "$AGE_VIOLATION_COUNT" -gt 0 ]; then + die "RELEASE_AGE_POLICY=off but AGE_VIOLATION_COUNT=$AGE_VIOLATION_COUNT (orchestrator bug)" + fi + if [ "$AGE_ERROR_COUNT" -gt 0 ]; then + die "RELEASE_AGE_POLICY=off but AGE_ERROR_COUNT=$AGE_ERROR_COUNT (orchestrator bug)" + fi + fi + error_total=$(( AGE_ERROR_COUNT + SCAN_ERROR_COUNT )) # --- gate_state (priority order) --- @@ -1117,7 +1142,7 @@ jobs: gate_state="error" elif [ "$error_total" -gt 0 ]; then gate_state="error" - elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$FAIL_ON_AGE_VIOLATION" = "true" ]; then + elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$RELEASE_AGE_POLICY" = "blocking" ]; then gate_state="failure" else gate_state="success" @@ -1158,16 +1183,24 @@ jobs: status_desc="Could not extract dependencies from diff. Manual review required." elif [ "$error_total" -gt 0 ]; then status_desc="Scan errors: ${AGE_ERROR_COUNT} age lookup(s), ${SCAN_ERROR_COUNT} advisory query/ies. Re-run or push to retry." - elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$FAIL_ON_AGE_VIOLATION" = "true" ]; then - status_desc="${AGE_VIOLATION_COUNT} package(s) younger than ${MINIMUM_RELEASE_AGE_DAYS}d cooldown — Dependabot native cooldown invariant violated." + elif [ "$AGE_VIOLATION_COUNT" -gt 0 ] && [ "$RELEASE_AGE_POLICY" = "blocking" ]; then + status_desc="${AGE_VIOLATION_COUNT} package(s) younger than ${MINIMUM_RELEASE_AGE_DAYS}d minimum release age (blocking policy)." elif [ "$ADVISORY_COUNT" -gt 0 ]; then status_desc="${ADVISORY_COUNT} advisory/ies found (version-filtered). Manual review required." elif [ "$AGE_VIOLATION_COUNT" -gt 0 ]; then - status_desc="${AGE_VIOLATION_COUNT} package(s) below ${MINIMUM_RELEASE_AGE_DAYS}d cooldown (advisory mode). Auto-merge suppressed." + status_desc="${AGE_VIOLATION_COUNT} package(s) below ${MINIMUM_RELEASE_AGE_DAYS}d minimum release age (advisory policy). Auto-merge suppressed." elif [ "$auto_merge_ok" = "true" ]; then - status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Auto-merge enabled." + if [ "$RELEASE_AGE_POLICY" = "off" ]; then + status_desc="Clean scan (no advisories). Auto-merge enabled." + else + status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Auto-merge enabled." + fi else - status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Ready for merge." + if [ "$RELEASE_AGE_POLICY" = "off" ]; then + status_desc="Clean scan (no advisories). Ready for merge." + else + status_desc="Clean scan (≥${MINIMUM_RELEASE_AGE_DAYS}d, no advisories). Ready for merge." + fi fi printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ @@ -1314,17 +1347,25 @@ jobs: fi fi - # --- Check release age via standalone script (Task 9 — phase 2) --- + # --- Check release age via standalone script (opt-in per release_age_policy) --- + # Positive allowlist: only the two known-enabled policies run lookups. + # Invalid values (e.g. "false" from unquoted YAML off, or typos) fall + # through with AGE_TSV empty — no network calls, no Release Age + # section — and the verdict layer then fails closed on the bad enum. 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 + case "$RELEASE_AGE_POLICY" in + advisory|blocking) + 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 + ;; + esac for ACTION in $ACTIONS; do [ -z "$ACTION" ] && continue @@ -1613,7 +1654,7 @@ jobs: AGE_VIOLATION_COUNT="$AGE_VIOLATION_COUNT" \ SCAN_ERROR_COUNT="$SCAN_ERROR_COUNT" \ ADVISORY_COUNT="$TOTAL" \ - FAIL_ON_AGE_VIOLATION="$FAIL_ON_AGE_VIOLATION" \ + RELEASE_AGE_POLICY="$RELEASE_AGE_POLICY" \ MINIMUM_RELEASE_AGE_DAYS="$MINIMUM_RELEASE_AGE_DAYS" \ AUTO_MERGE="$AUTO_MERGE" \ safety_verdict); then @@ -1646,7 +1687,7 @@ jobs: LABEL_COLOR_security_review_needed="B60205" LABEL_DESC_security_review_needed="Dependency scan found advisories — manual review required" LABEL_COLOR_dependency_age_violation="FBCA04" - LABEL_DESC_dependency_age_violation="Target version younger than the configured minimum release age (Dependabot native cooldown invariant)" + LABEL_DESC_dependency_age_violation="Target version younger than the configured minimum release age (release_age_policy)" LABEL_COLOR_dependency_safety_error="6E7781" LABEL_DESC_dependency_safety_error="dependency-safety scan could not complete reliably — see scan comment" @@ -1734,7 +1775,7 @@ jobs: fi # ---- Header + footer (from verdict priority — must match safety-verdict.sh order) ---- - # Priority: guard > error > strict-age > advisories > non-strict-age > clean. + # Priority: guard > error > blocking-age > advisories > advisory-age > clean. # Spec §6.1 — keep this order in sync with the script. if [ "$GUARD_TRIGGERED" = "true" ]; then RESULTS_HEADER="Dependency extraction failed — manual review required." @@ -1747,8 +1788,8 @@ jobs: elif [ "$HAS_SAFETY_ERROR" = "true" ]; then RESULTS_HEADER="Scan completed with errors — see workflow logs." RESULTS_FOOTER="> Scan errors prevented a clean verdict. Re-run or push to retry." - elif [ "$HAS_AGE_VIOLATION" = "true" ] && [ "$FAIL_ON_AGE_VIOLATION" = "true" ]; then - RESULTS_HEADER="${AGE_VIOLATION_COUNT} package(s) younger than ${MINIMUM_RELEASE_AGE_DAYS}-day cooldown — Dependabot native cooldown invariant violated." + elif [ "$HAS_AGE_VIOLATION" = "true" ] && [ "$RELEASE_AGE_POLICY" = "blocking" ]; then + RESULTS_HEADER="${AGE_VIOLATION_COUNT} package(s) younger than the ${MINIMUM_RELEASE_AGE_DAYS}-day minimum release age (blocking policy)." RESULTS_FOOTER="> Re-run by rebasing or recreating the PR after the age-compliant date below." if [ "$TOTAL" -gt 0 ]; then # Cross-reference: advisories are present too. Don't lose that signal under the age-violation header. @@ -1758,9 +1799,9 @@ jobs: RESULTS_HEADER="**${TOTAL} advisory/ies found** affecting target versions — review before merging." RESULTS_FOOTER="> Review the advisories above before deciding to merge." elif [ "$HAS_AGE_VIOLATION" = "true" ]; then - # fail_on_age_violation=false — advisory mode, not blocking. - RESULTS_HEADER="${AGE_VIOLATION_COUNT} package(s) below ${MINIMUM_RELEASE_AGE_DAYS}-day cooldown (advisory mode — not blocking)." - RESULTS_FOOTER="> Age violation reported in advisory mode (not blocking). Auto-merge suppressed." + # release_age_policy: advisory — reported, not blocking. + RESULTS_HEADER="${AGE_VIOLATION_COUNT} package(s) below the ${MINIMUM_RELEASE_AGE_DAYS}-day minimum release age (advisory policy — not blocking)." + RESULTS_FOOTER="> Release-age violation reported (advisory policy — not blocking). Auto-merge suppressed." else RESULTS_HEADER="No known exploits found affecting the target versions." if [ "$AUTO_MERGE_OK" = "true" ]; then diff --git a/tests/safety-workflow-contract.bats b/tests/safety-workflow-contract.bats new file mode 100644 index 0000000..6c48cb5 --- /dev/null +++ b/tests/safety-workflow-contract.bats @@ -0,0 +1,44 @@ +#!/usr/bin/env bats +# safety-workflow-contract.bats — static assertions that the public +# workflow_call surface of dependency-safety.yml matches the v4 contract +# (issue #85): release_age_policy default "off", auto_merge default true, +# minimum_release_age_days default 5, fail_on_age_violation removed. +# +# Why: safety-verdict.bats proves the verdict logic; it does not prove the +# reusable workflow's declared inputs satisfy the documented contract. + +YAML=".github/workflows/dependency-safety.yml" + +# input_default — print the `default:` value declared for an +# input in the workflow_call inputs block (input names at 6-space indent, +# properties at 8-space indent). +input_default() { + awk -v key=" $1:" ' + $0 == key { found=1; next } + found && /^ default:/ { sub(/^ default: */, ""); print; exit } + found && /^ [a-z_]+:$/ { exit } + ' "$YAML" +} + +@test "dependency-safety.yml: auto_merge defaults to true" { + [ "$(input_default auto_merge)" = "true" ] +} + +@test "dependency-safety.yml: release_age_policy is a string input with quoted default \"off\"" { + grep -q '^ release_age_policy:$' "$YAML" + awk '/^ release_age_policy:$/{f=1;next} f&&/^ [a-z_]+:$/{exit} f' "$YAML" | grep -q 'type: string' + [ "$(input_default release_age_policy)" = '"off"' ] +} + +@test "dependency-safety.yml: minimum_release_age_days defaults to 5" { + [ "$(input_default minimum_release_age_days)" = "5" ] +} + +@test "dependency-safety.yml: fail_on_age_violation is fully removed" { + count=$(grep -ci 'fail_on_age_violation' "$YAML" || true) + [ "$count" -eq 0 ] +} + +@test "dependency-safety.yml: RELEASE_AGE_POLICY env is wired from inputs" { + grep -qF 'RELEASE_AGE_POLICY: ${{ inputs.release_age_policy }}' "$YAML" +} From bb6311f3ef079d3b28a593a304e8cdc6b507c149 Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 11 Jun 2026 07:15:32 -0700 Subject: [PATCH 3/5] feat(safety)!: rely on v4 defaults in ci-safety dogfood caller BREAKING CHANGE: post-PR release-age verification is now opt-in via release_age_policy (default "off") and auto_merge defaults to true; the fail_on_age_violation input is removed. Refs #85 --- .github/workflows/ci-safety.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci-safety.yml b/.github/workflows/ci-safety.yml index 90cd311..9529bdd 100644 --- a/.github/workflows/ci-safety.yml +++ b/.github/workflows/ci-safety.yml @@ -18,5 +18,3 @@ concurrency: jobs: safety: uses: ./.github/workflows/dependency-safety.yml - with: - auto_merge: true From 109b502aa0c5b1a9d026ee23c8adad95c608a122 Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 11 Jun 2026 07:22:26 -0700 Subject: [PATCH 4/5] feat(safety)!: document v4 release-age policy, auto-merge default, and v3-to-v4 migration BREAKING CHANGE: post-PR release-age verification is now opt-in via release_age_policy (default "off") and auto_merge defaults to true; the fail_on_age_violation input is removed. Refs #85 --- .github/workflows/README.md | 12 ++-- README.md | 111 ++++++++++++++++++++++-------------- 2 files changed, 74 insertions(+), 49 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e2c692e..31873f8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,8 +1,8 @@ # Reusable Workflows -This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers reference them via `uses: j7an/shared-workflows/.github/workflows/@v3`. +This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers reference them via `uses: j7an/shared-workflows/.github/workflows/@v4`. -> **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. +> **Note:** `@v3` and `@v2` continue to work at their last-released revisions, but receive no further updates. See the root README's "v3 → v4 migration" section. ## `tag-release.yml` @@ -32,7 +32,7 @@ on: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v4 with: bump: ${{ inputs.bump }} # tag-prefix omitted → defaults to "v" → produces v1.2.3 @@ -49,7 +49,7 @@ on: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v4 with: bump: ${{ inputs.bump }} tag-prefix: "tools/v" # produces tools/v0.1.0 @@ -186,7 +186,7 @@ on: jobs: publish: - uses: j7an/shared-workflows/.github/workflows/publish-pypi.yml@v3 + uses: j7an/shared-workflows/.github/workflows/publish-pypi.yml@v4 with: tag: ${{ github.ref_name }} package-dir: tools @@ -198,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 `v3`, environment `pypi`. +- [ ] On PyPI, configure trusted publisher: workflow `j7an/shared-workflows/.github/workflows/publish-pypi.yml`, ref `v4`, 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/README.md b/README.md index 6a3d3b2..39eb397 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ Reusable GitHub Actions workflows for dependency safety verification and release ## Features -- **Native-cooldown verification** — Dependabot's native `cooldown.default-days` owns the wait; `dependency-safety.yml` verifies the invariant on every scan and fails deterministically on violation +- **Opt-in release-age verification** — Dependabot's native `cooldown.default-days` owns the wait before PR creation; `release_age_policy` (default `"off"`) can additionally label (`advisory`) or fail the gate (`blocking`) on target versions younger than `minimum_release_age_days` - **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 - **Update-or-create scan comments** — a single stable comment per PR; change detection posts a top-level PR comment only when advisory IDs actually change -- **Optional auto-merge** — clean scans flip on `gh pr merge --auto`; dirty scans apply labels (`security-review-needed`, `dependency-age-violation`, or `dependency-safety-error`) instead +- **Auto-merge by default** — clean scans enable `gh pr merge --auto` (set `auto_merge: false` to opt out); dirty scans apply labels (`security-review-needed`, `dependency-age-violation`, or `dependency-safety-error`) instead - **Grouped PR support** — handles both single-package and grouped Dependabot PRs ## Prerequisites @@ -19,17 +19,12 @@ Reusable GitHub Actions workflows for dependency safety verification and release - **No Renovate** — this workflow only scans `dependabot[bot]` PRs; other actors are passed through with a success status (except external fork PRs, whose read-only token can't post the status — see [Fork PRs and the required gate](#fork-prs-and-the-required-gate)) > **Scope: version-update PRs.** Dependabot's native `cooldown:` setting applies -> only to *version updates*, not [security updates][gh-cooldown-scope]. The -> default `fail_on_age_violation: true` therefore treats young security-fix PRs -> as invariant violations even though native cooldown never held them. For -> repos with Dependabot security updates enabled, choose one: -> -> - **Advisory mode (interim):** set `fail_on_age_violation: false` so age -> violations apply the `dependency-age-violation` label instead of failing -> the gate. The trade-off: the strict invariant is weakened for *all* -> Dependabot PRs, not just security ones. -> - **Wait for follow-up detection** that treats security-update PRs as a -> distinct class (tracked separately; not in this release). +> only to *version updates*, not [security updates][gh-cooldown-scope]. With the +> default `release_age_policy: "off"` this distinction has no effect — the +> workflow performs no post-PR age checks. If you opt into `advisory` or +> `blocking`, young security-fix PRs will be flagged (or blocked) even though +> native cooldown never held them — prefer `advisory` if that trade-off is not +> acceptable for your repo. > > [gh-cooldown-scope]: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown-- @@ -37,7 +32,7 @@ Reusable GitHub Actions workflows for dependency safety verification and release ### 1. Configure Dependabot cool-down -The waiting period is owned by Dependabot itself — the workflow only verifies that the invariant holds. Add `cooldown:` to `.github/dependabot.yml`: +The waiting period is owned by Dependabot itself — by default the workflow does not re-verify it (opt in with `release_age_policy`). Add `cooldown:` to `.github/dependabot.yml`: ```yaml # .github/dependabot.yml @@ -78,17 +73,16 @@ concurrency: jobs: safety: - uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v3 + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4 secrets: inherit - with: - auto_merge: true ``` -`contents: write` is only required when `auto_merge: true`; otherwise `contents: read` is sufficient. +`auto_merge` defaults to `true` and requires `contents: write`. If you grant only `contents: read`, set `auto_merge: false`. -> **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). +> **Note:** `@v4` is the current floating major. `@v3` (release-age enforcement +> on by default, auto-merge opt-in) and `@v2` (last cooldown-bearing line) +> continue to work but receive no further updates. Releases in this repo are +> dispatched manually — see [Versioning](#versioning). > **External fork PRs:** by default GitHub gives `GITHUB_TOKEN` a **read-only** > token on PRs from forks even when you declare `statuses: write` — unless a @@ -104,9 +98,9 @@ jobs: | Input | Type | Default | Description | |-------|------|---------|-------------| | `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 appropriate label | -| `minimum_release_age_days` | number | `5` | Floor for target-version release age. Verified at scan time; should match `cooldown.default-days` in `dependabot.yml` | -| `fail_on_age_violation` | boolean | `true` | If `true`, age violations set the gate status to `failure`. If `false`, the gate is `success` with a `dependency-age-violation` label and a comment; auto-merge is suppressed in either case | +| `auto_merge` | boolean | `true` | On clean scans, enable `gh pr merge --auto` (requires `contents: write`); on dirty scans, apply the appropriate label. Set `false` for manual merges | +| `release_age_policy` | string | `"off"` | Post-PR release-age verification: `off` (no age lookup), `advisory` (label + comment on young targets, gate stays green, auto-merge suppressed), `blocking` (gate fails). Quote `"off"` in YAML | +| `minimum_release_age_days` | number | `5` | Threshold used when `release_age_policy` is `advisory` or `blocking`; ignored when `off`. Should match `cooldown.default-days` in `dependabot.yml` | ## Supported Ecosystems @@ -132,7 +126,7 @@ dependency-safety.yml fires on pull_request ├── Parses diff to extract package names + target versions │ ├── Falls back to PR body text when inline versions are absent │ └── Supports github-actions, pip, and uv ecosystems - ├── Verifies release age — fails if any target < minimum_release_age_days + ├── Verifies release age (only when release_age_policy is advisory or blocking; blocking fails the gate, advisory labels + suppresses auto-merge) ├── For each package: │ ├── GHSA GraphQL query (by ecosystem) │ ├── OSV.dev POST query (with version if known) @@ -144,7 +138,7 @@ dependency-safety.yml fires on pull_request ├── Reconciles labels (security-review-needed, dependency-age-violation, dependency-safety-error) ├── Update-or-create single scan comment ├── If advisory IDs changed since last scan → post change-notification top-level PR comment - ├── If clean and auto_merge=true → gh pr merge --auto + ├── If clean and auto_merge=true (default) → gh pr merge --auto └── Sets final gate status (success / failure / error) ``` @@ -162,8 +156,8 @@ The `dependency-safety / gate` commit status uses three states: | State | When | |-------|------| -| `success` | Clean scan, OR advisories present (label `security-review-needed`), OR age violation in advisory mode (label `dependency-age-violation`, `fail_on_age_violation: false`) | -| `failure` | Strict age violation (`fail_on_age_violation: true`) — the Dependabot native cooldown invariant was violated | +| `success` | Clean scan, OR advisories present (label `security-review-needed`), OR age violation under `release_age_policy: advisory` (label `dependency-age-violation`) | +| `failure` | Age violation under `release_age_policy: blocking` | | `error` | Dependency extraction failed, or GHSA/OSV/age-lookup APIs errored — the verdict is unreliable; manual review required | Labels: @@ -171,7 +165,7 @@ Labels: | Label | Color | Applied when | Removed when | |-------|-------|--------------|--------------| | `security-review-needed` | red (`B60205`) | Advisory scan finds vulnerabilities affecting target versions | Re-scan finds zero applicable advisories AND no `error` state | -| `dependency-age-violation` | amber (`FBCA04`) | Any target version is younger than `minimum_release_age_days` | All versions pass age check AND no `error` state | +| `dependency-age-violation` | amber (`FBCA04`) | Any target version is younger than `minimum_release_age_days` (only under `release_age_policy: advisory` or `blocking`) | All versions pass age check AND no `error` state | | `dependency-safety-error` | grey (`6E7781`) | Scan extraction failed or API errors occurred | Clean scan completes without errors | Reconciliation is authoritative when the scan succeeds. On the `error` path, labels are preserved (not removed) since the verdict is unreliable. @@ -287,6 +281,37 @@ expected here; the constraints above (no `checkout`, `statuses: write` only, ecosystem block — avoids `@dependabot rebase` pulling in newer versions that have not yet aged through native cooldown. +## v3 → v4 migration + +`v4.0.0` makes post-PR release-age verification opt-in and enables auto-merge +by default. Dependabot native `cooldown.default-days` remains the recommended +mechanism for delaying version-update PRs; the workflow no longer re-verifies +release age unless asked to. + +1. **Map `fail_on_age_violation` to `release_age_policy`.** The old input is + removed — passing it to `@v4` fails at startup with "Invalid input": + + | v3 | v4 | + |----|----| + | `fail_on_age_violation: true` (or unset) | `release_age_policy: blocking` | + | `fail_on_age_violation: false` | `release_age_policy: advisory` | + | — (new default: no post-PR age checks) | omit the input (defaults to `"off"`) | + +2. **Auto-merge now defaults to on.** Set `auto_merge: false` to keep manual + merges. With auto-merge on, the calling job must grant `contents: write`. + +3. **Keep (or add) native cooldown** in `.github/dependabot.yml` — under the + default `release_age_policy: "off"` the workflow no longer verifies the + age invariant, so `cooldown.default-days` is the only waiting period. + +4. **Quote the policy value if you restate it.** `release_age_policy: "off"` + — unquoted `off` is a YAML boolean literal and may not survive parsing as + a string. `minimum_release_age_days` only takes effect with `advisory` or + `blocking`. + +5. **Stale labels self-heal.** A leftover `dependency-age-violation` label + from v3 is removed on the first error-free v4 scan of that PR. + ## 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: @@ -444,12 +469,12 @@ permissions: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v4 secrets: RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} publish: needs: tag - uses: j7an/shared-workflows/.github/workflows/release.yml@v3 + uses: j7an/shared-workflows/.github/workflows/release.yml@v4 with: tag: ${{ needs.tag.outputs.tag }} ``` @@ -476,14 +501,14 @@ permissions: jobs: tag: - uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v3 + uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v4 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@v3 + uses: j7an/shared-workflows/.github/workflows/release.yml@v4 with: tag: ${{ needs.tag.outputs.tag }} ``` @@ -501,16 +526,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 `@v3` pin +### On the `@v4` 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) +`@v4` is the floating major tag for the current `v4.x.y` line. It always +points at the latest `v4.x.y` release because `release.yml` force-updates +floating majors on every publish. Pinning to `@v4` means you get all +non-breaking updates within v4 automatically. Pin to `@v4.0` for patch-only +updates, or `@v4.0.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)). +`@v3` is the previous line, frozen at the last release where post-PR +release-age verification was on by default and auto-merge was opt-in. `@v2` +is the frozen historical cooldown-bearing line. Both continue to work but +receive no further updates — see [v3 → v4 migration](#v3--v4-migration). From e7b8e082b9239f98343b35b6b8e12a5e0e1fd8ec Mon Sep 17 00:00:00 2001 From: j7an Date: Thu, 11 Jun 2026 07:49:00 -0700 Subject: [PATCH 5/5] feat(safety)!: update agent guidance for the release_age_policy model BREAKING CHANGE: post-PR release-age verification is now opt-in via release_age_policy (default "off") and auto_merge defaults to true; the fail_on_age_violation input is removed. Refs #85 --- .claude/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 192cd8f..b6f0f86 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/@v3`. 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/@v4`. There is no application code — the deliverables are the workflow YAMLs in `.github/workflows/` and the bash logic in `scripts/`. ## Commands @@ -40,7 +40,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: 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. +- `dependency-safety.yml` — scans each Dependabot PR for advisories; post-PR release-age verification is opt-in via `release_age_policy` (default `"off"`; `advisory` labels + suppresses auto-merge, `blocking` fails the gate), and `auto_merge` defaults to `true`. Pipeline: extract → fallback → guard → age check (policy-gated) → GHSA/OSV scan → scorecard → comment → labels; the verdict layer is deterministic: `failure` on age violation only under `blocking`, `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.