fix(gate): the tooling-partition gate states its own remedy (BACKLOG #1434) #2498
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Dependabot auto-merge | |
| # Scoped auto-merge for Dependabot PRs (A4 of the dependency fast-response plan). | |
| # | |
| # SAFE because main's required status checks are the gate: the full pytest suite, pip-audit, the | |
| # DEP-1 lock-sync check (incl. the A3 lock-resync commit), bandit/semgrep/gitleaks all must pass | |
| # before GitHub completes the merge. A bad bump turns CI red and never merges — auto-merge only | |
| # removes the human-latency on the safe, common case, not the safety net. | |
| # | |
| # IN SCOPE (auto-merged): | |
| # - a PATCH update (or a MINOR of a DEV-only dependency) whose EVERY named dependency sits on its | |
| # own ecosystem's ALLOW row (guardrail #3 below). | |
| # OUT OF SCOPE (left for human review, surfaced same-day by the daily security cron + alerts): | |
| # - EVERYTHING ELSE, and that is the default by design. An unnamed dependency, an unrecognised | |
| # ecosystem, a MINOR/MAJOR update of a runtime dep, any MAJOR update — every one of them HOLDS. | |
| # - EVERY uv and npm bump, because both allow rows ship EMPTY. `python-deps`/`ide-deps` group on | |
| # `*`, so one weekly batch carries everything and a non-empty row would have to name each of the | |
| # ~98 pinned distributions before it could ever fire. Those two ecosystems were already | |
| # review-by-default in practice; they are now review-by-default BY DESIGN rather than by accident. | |
| # - a github-actions batch carrying any third-party action, since `actions-deps` also groups on `*`. | |
| # Measured: PR #75's five-bump batch carried `pypa/gh-action-pypi-publish`, so that same batch | |
| # would HOLD under this allow-set. Expect auto-merge to fire RARELY — that is the intent of | |
| # "hold unless named", not a regression. | |
| # | |
| # Fresh-release supply-chain poisoning is handled upstream by the dependabot.yml `cooldown`, which | |
| # every configured ecosystem carries: a routine update ages before its PR opens. The per-ecosystem | |
| # windows — and the caveat that `github-actions` ages off the tag's COMMIT date rather than the | |
| # release date — live in `.github/dependabot.yml` beside each value's rationale. Read them there; a | |
| # copy here would rot silently. SECURITY updates bypass cooldown by Dependabot design, so that track | |
| # is aged HERE instead, workflow-side, by guardrail #4. | |
| # | |
| # AUTO-MERGE GUARDRAILS. #2 and #3 keep DEPENDENCY-POSTURE-REVIEW.md's numbering (SEC-007, CWE-829), | |
| # with two corrections that document does not yet carry: #3 is INVERTED from the deny-list it | |
| # describes, and #4 is introduced HERE — the posture review does not contain it. Only #2 and #4 are | |
| # security-track-scoped; #3 gates EVERY PR. | |
| # #3 ALLOW-SET — HOLD UNLESS NAMED, on every track. A PR auto-merges only when EVERY dependency it | |
| # touches is on its own ecosystem's allow row; an unnamed dependency, or an ecosystem this step | |
| # does not recognise, routes the PR to MANUAL review. A grouped PR is denied WHOLE on any single | |
| # ineligible member (fail-safe — never partial-merge a group). This INVERTS the former | |
| # deny-list, whose default was merge-unless-named across every ecosystem at once. | |
| # #2 PUBLISHED-GHSA GATE (security track only) — a SECURITY-track PR (which bypasses the cooldown | |
| # by Dependabot design) auto-merges ONLY when a real, published, non-withdrawn advisory is | |
| # confirmed against the dependency's PREVIOUS version (the axios-1.14.1 pattern). The advisory | |
| # lookup FAILS CLOSED: a rate-limit/API error or no-matching-advisory routes to manual review, | |
| # never auto-merge. The non-security VERSION track keeps its cooldown-aged auto-merge unchanged | |
| # (already aged by dependabot.yml, so it does not need the GHSA gate). | |
| # #4 RELEASE AGE (security track only) — a SECURITY-track PR additionally requires every candidate | |
| # version to have been published at least MIN_RELEASE_AGE_HOURS ago, because that track is the | |
| # one the upstream cooldown cannot age. FAILS CLOSED identically to #2: an API error, a missing | |
| # or unparseable upload timestamp, a package name/version that does not match its expected | |
| # shape, or an ecosystem with no publish-date source wired all route to manual review. | |
| # READ THIS BEFORE CITING #4 AS A CONTROL: it IS INERT with respect to the merge decision as | |
| # shipped, and recording it as operating would be exactly the false premise | |
| # docs/Secure_Development_Standards.md §3 forbids. | |
| # `age_ok=true` is reachable only for uv/pip (no publish-date source is wired for any | |
| # other ecosystem), while `eligible=true` is reachable only for github-actions (the uv/pip/npm | |
| # allow rows ship EMPTY). Those two sets are DISJOINT, so no PR can satisfy both conjuncts the | |
| # merge `if` requires. #4 is a FORWARD guard — built, tested and gated now, load-bearing the day | |
| # a Python allow row is populated or the advisory gate is made ecosystem-aware. Populating an | |
| # allow row is the owner's call, not this workflow's. | |
| # | |
| # RESIDUAL ACCEPTED RISK, stated at the ONE path that can actually auto-merge: a VERSION-track patch | |
| # (or dev-only minor) bumping a GitHub-published action. Its only aging is dependabot.yml's cooldown, | |
| # which that file itself qualifies as approximate for this ecosystem, so a malicious release from a | |
| # compromised GitHub-owned publisher would still auto-merge. Neither #2 nor #4 narrows that path — | |
| # both are security-track-only, and this path is the version track. | |
| # What main's required CI does and does not backstop, stated precisely: pip-audit reads the | |
| # committed LOCKS for already-published advisories, and bandit/semgrep read THIS repository's own | |
| # source — so none of them ever reads a dependency's contents, and a package that was malicious at | |
| # publish time is not a known CVE. What those required checks DO catch is a bump that breaks the | |
| # build or regresses a KNOWN advisory. No REQUIRED check reads a dependency's shipped bytes. | |
| # security.yml's `trivy` job scans the built engine image, but it is advisory (`continue-on-error`) | |
| # and cron/dispatch-only, so it never runs on this PR — and it matches an advisory database | |
| # regardless. Treat the allow-set plus the aging above as the control, not a downstream scanner. | |
| # | |
| # Why `pull_request` (not pull_request_target): a Dependabot `pull_request` run gets a read-only | |
| # GITHUB_TOKEN by default, which the `permissions:` block below elevates to exactly what the merge | |
| # API needs — no untrusted-code-with-secrets exposure, no GitHub App required. This is the pattern | |
| # GitHub documents for Dependabot auto-merge. | |
| on: pull_request | |
| permissions: | |
| contents: write # complete the merge | |
| pull-requests: write # enable auto-merge on the PR | |
| security-events: read # read Dependabot alerts for the published-GHSA gate (#2) | |
| concurrency: | |
| group: dependabot-automerge-${{ github.event.pull_request.number }} | |
| cancel-in-progress: false | |
| jobs: | |
| auto-merge: | |
| runs-on: ubuntu-latest | |
| # Only Dependabot's own PRs. pull_request.user.login is the immutable PR author, so an A3 | |
| # lock-resync `synchronize` (pushed by the App) still satisfies this — the author stays | |
| # dependabot[bot] even though the triggering actor is the App. | |
| if: github.event.pull_request.user.login == 'dependabot[bot]' | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Fetch Dependabot metadata | |
| id: meta | |
| uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| # Guardrail #3 (DEPENDENCY-POSTURE-REVIEW.md), INVERTED from a deny-list to an ALLOW-SET: the | |
| # default is HOLD, and only a dependency named on its own ecosystem's allow row is eligible. | |
| # dependency-names is comma-separated for grouped PRs; the group is denied WHOLE if ANY member | |
| # is ineligible — the same fail-safe the deny-list had, never a partial group merge. Measured | |
| # consequence: PR #75's five-bump batch carried `pypa/gh-action-pypi-publish`, which is NOT on | |
| # the allow row, so that batch would now HOLD — and that action runs in the `id-token: write` | |
| # publish job, which is exactly the case worth holding. | |
| # | |
| # Every input arrives via `env:`; nothing is interpolated into the body. That is zizmor | |
| # template-injection parity, and it is what lets the behavioural tests execute this body | |
| # verbatim under `bash -e` — the shell Actions applies by default, which those tests mirror. | |
| - name: Hold everything not on the auto-merge allow-set | |
| id: allowset | |
| env: | |
| DEP_NAMES: ${{ steps.meta.outputs.dependency-names }} | |
| DEP_ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} | |
| run: | | |
| set -u | |
| # Normalise with bash parameter expansion only (no echo|tr|xargs) so this block depends on | |
| # nothing outside bash itself and stays executable from pytest on any platform. | |
| eco="${DEP_ECOSYSTEM,,}" | |
| eco="${eco//[[:space:]]/}" | |
| # These tokens are Dependabot BRANCH-NAME forms, not the dependabot.yml spellings: | |
| # fetch-metadata derives packageEcosystem from chunks[1] of the branch name. Measured on | |
| # live PRs — #135 `dependabot/uv/…`, #75 `dependabot/github_actions/…`, #64 | |
| # `dependabot/npm_and_yarn/ide/…`. Both spellings are accepted so a future rename of either | |
| # side still matches; an UNRECOGNISED token falls to `*)` and holds rather than merging. | |
| case "$eco" in | |
| github_actions|github-actions) | |
| # GitHub-published actions only. GitHub already owns the runner and the job's token, so | |
| # these add no trust root that is not already fully trusted. Deliberately NOT here: | |
| # pypa/gh-action-pypi-publish, astral-sh/setup-uv, ossf/scorecard-action, | |
| # contributor-assistant/github-action — every third-party action holds. | |
| allow_prefixes="actions/ github/ dependabot/" | |
| ;; | |
| uv|pip|npm_and_yarn|npm) | |
| # Deliberately EMPTY. `python-deps`/`ide-deps` group on `*`, so one weekly batch carries | |
| # everything and a non-empty row would have to name every one of the ~98 pinned | |
| # distributions before it could ever fire. These ecosystems were already | |
| # review-by-default in practice; this makes it review-by-default BY DESIGN. | |
| allow_prefixes="" | |
| ;; | |
| *) | |
| echo "::notice::unrecognised package ecosystem '${eco:-<none>}' — holding for manual review." | |
| allow_prefixes="" | |
| ;; | |
| esac | |
| eligible=true | |
| saw_any=false | |
| IFS=',' read -ra names <<< "$DEP_NAMES" | |
| for raw in "${names[@]}"; do | |
| name="${raw,,}" | |
| name="${name//[[:space:]]/}" | |
| [ -z "$name" ] && continue | |
| saw_any=true | |
| matched=false | |
| for p in $allow_prefixes; do | |
| # ANCHORED prefix test, and the trailing slash in each prefix is load-bearing. The two | |
| # near-misses fail for DIFFERENT reasons, so both are pinned by tests: a substring | |
| # match would admit `evil/actions/checkout`, and dropping the trailing slash would | |
| # admit `actionsx/foo`. | |
| case "$name" in | |
| "$p"*) matched=true ;; | |
| esac | |
| done | |
| if [ "$matched" != true ]; then | |
| echo "::notice::'$name' is not on the '${eco:-<none>}' auto-merge allow-set — routing the WHOLE PR to manual review." | |
| eligible=false | |
| fi | |
| done | |
| # An empty name list cannot CONFIRM eligibility, so it must not be read as confirming it. | |
| [ "$saw_any" = true ] || eligible=false | |
| echo "eligible=$eligible" >> "$GITHUB_OUTPUT" | |
| # Guardrail #2 (DEPENDENCY-POSTURE-REVIEW.md): a SECURITY-track PR auto-merges ONLY when a | |
| # real, published, non-withdrawn advisory covers the dependency's PREVIOUS version. The | |
| # security track is detected by Dependabot's `dependency-group` (python-security/ide-security) | |
| # — a security-update group — falling back to a present-but-empty group meaning version track. | |
| # The advisory lookup FAILS CLOSED: any error or no-match leaves advisory_ok=false. | |
| - name: Verify a published advisory backs the security track | |
| id: ghsa | |
| env: | |
| DEP_GROUP: ${{ steps.meta.outputs.dependency-group }} | |
| DEPS_JSON: ${{ steps.meta.outputs.updated-dependencies-json }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -u | |
| # Is this the security track? Dependabot tags security-update grouped PRs with a | |
| # *-security dependency-group. A non-security (version) PR has no security group. | |
| is_security=false | |
| case "$DEP_GROUP" in | |
| *security*) is_security=true ;; | |
| esac | |
| if [ "$is_security" != "true" ]; then | |
| # Version track: already cooldown-aged by dependabot.yml — the GHSA gate does not apply. | |
| echo "security_track=false" >> "$GITHUB_OUTPUT" | |
| echo "advisory_ok=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "security_track=true" >> "$GITHUB_OUTPUT" | |
| # Require at least one matching published (non-withdrawn) advisory for EVERY named dep at | |
| # the previous version. Query the global advisories endpoint (ecosystem pip — Python | |
| # advisories are pip-keyed). Fail closed on any error. | |
| # fetch-metadata's scalar `previous-version` is ONE dependency's even on a grouped PR (#66: | |
| # five deps, previous-version=0.7.0 — annotated-types'). Keying every name to it asks about | |
| # versions the PR does not touch, and because most advisory ranges are open at the bottom, a | |
| # low first-dep version makes almost every other name match: false confirmation in the | |
| # PERMISSIVE direction (measured against the live endpoint at @0.7.0: aiohttp 30, pillow 29, | |
| # urllib3 9, requests 6). `updated-dependencies-json` carries a per-dependency prevVersion. | |
| # `||` outside the substitution (see the advisory lookup below). It matters here for a | |
| # second reason: jq STREAMS, so a mid-array error can leave earlier rows already on stdout, | |
| # and the old form would have appended ERR to a TRUNCATED dependency list — iterating a | |
| # subset of the PR's dependencies while still reporting success. Assigning on failure | |
| # discards the partial output instead of inheriting it. | |
| pairs="$(printf '%s' "$DEPS_JSON" | jq -r '.[] | [.dependencyName, .prevVersion] | @tsv' 2>/dev/null)" || pairs="ERR" | |
| if [ "$pairs" = "ERR" ] || [ -z "$pairs" ]; then | |
| echo "::warning::no per-dependency metadata — failing closed (manual review)." | |
| echo "advisory_ok=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| advisory_ok=true | |
| while IFS="$(printf '\t')" read -r raw prev; do | |
| name="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | xargs)" | |
| if [ -z "$name" ]; then continue; fi | |
| # A non-first dependency can carry an EMPTY prevVersion — update_metadata.ts:101 falls back | |
| # to '' for index > 0 when the PR body yields no "from" version. Unknown previous version | |
| # means the advisory question cannot be asked, so DENY; skipping it would wave the dep | |
| # through on a gate whose whole contract is to fail closed. | |
| if [ -z "$prev" ]; then | |
| echo "::warning::no previous version for '$name' — failing closed (manual review)." | |
| advisory_ok=false | |
| break | |
| fi | |
| # The `||` binds the ASSIGNMENT, never the substitution. `x=$(cmd || echo ERR)` APPENDS | |
| # the sentinel to whatever cmd already wrote to stdout, and `gh api` copies the JSON | |
| # error BODY to stdout on any HTTP error (only the `gh: ... (HTTP nnn)` line goes to | |
| # stderr, which `2>/dev/null` eats). The old form left `count` holding | |
| # `{"message":"API rate limit exceeded",...}ERR` — neither "ERR" nor empty, so the | |
| # sentinel MISSED; `[ "$count" -lt 1 ]` then failed with "integer expression expected" | |
| # and returned 2, which an `if` condition is exempt from under `set -e`. The step printed | |
| # "advisory confirmed", wrote advisory_ok=true and exited 0 — this guard inverted to | |
| # FAIL-OPEN on precisely the rate-limit/API-error class the comment above names first. | |
| count="$(gh api -X GET /advisories \ | |
| -f ecosystem=pip \ | |
| -f affects="${name}@${prev}" \ | |
| --jq '[.[] | select(.withdrawn_at == null)] | length' 2>/dev/null)" || count="ERR" | |
| # A SHAPE check, not equality against one sentinel. The numeric comparison below needs | |
| # "is this a number" answered, and only a shape test answers it for values nobody | |
| # anticipated — an equality test recognises exactly the failure it was told about, which | |
| # is how a JSON body walked through the old guard. | |
| case "$count" in | |
| ""|*[!0-9]*) | |
| echo "::warning::advisory lookup failed for '$name' — failing closed (manual review)." | |
| advisory_ok=false | |
| break | |
| ;; | |
| esac | |
| if [ "$count" -lt 1 ]; then | |
| echo "::warning::no published advisory covers '${name}@${prev}' — failing closed (manual review)." | |
| advisory_ok=false | |
| break | |
| fi | |
| echo "::notice::published advisory confirmed for '${name}@${prev}'." | |
| done <<< "$pairs" | |
| echo "advisory_ok=$advisory_ok" >> "$GITHUB_OUTPUT" | |
| # Guardrail #4: the SECURITY track bypasses dependabot.yml's `cooldown` by Dependabot design, | |
| # so it is aged HERE instead — the candidate version must have been published at least | |
| # MIN_RELEASE_AGE_HOURS ago. This defeats the fast-publish-then-yank pattern (axios-1.14.1) that | |
| # a cooldown would have caught on the version track. It costs unattended-merge LATENCY only: a | |
| # held PR stays open and a human can still merge a genuine CVE fix immediately. | |
| # | |
| # The security-track discriminator is CONSUMED from the ghsa step rather than re-derived, so | |
| # there is exactly one place that decides what "security track" means. | |
| # | |
| # The two cheapest fail-closed guards run BEFORE jq deliberately: that keeps them exercisable | |
| # off the ubuntu leg, where jq is not guaranteed to exist. | |
| # | |
| # Gated on the allow-set because the merge `if` ALREADY requires that conjunct, so skipping here | |
| # changes no merge outcome: a skipped step emits no `age_ok`, and the merge `if` reads a missing | |
| # output as empty, which is not 'true'. What it does remove is an UNAUTHENTICATED outbound GET | |
| # made from a job holding `contents: write`, on a PR that was going to be held anyway. | |
| - name: Require the candidate release to have aged (security track) | |
| id: age | |
| if: steps.allowset.outputs.eligible == 'true' | |
| env: | |
| MIN_RELEASE_AGE_HOURS: "24" | |
| SECURITY_TRACK: ${{ steps.ghsa.outputs.security_track }} | |
| DEP_ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} | |
| DEPS_JSON: ${{ steps.meta.outputs.updated-dependencies-json }} | |
| run: | | |
| set -u | |
| if [ "$SECURITY_TRACK" != "true" ]; then | |
| # Version track: already aged upstream by dependabot.yml's `cooldown`, so this gate does | |
| # not apply. Emit FALSE even on this safe path — never `true` — mirroring the ghsa step | |
| # exactly, so that no early exit anywhere in this step can fail OPEN. The merge `if` | |
| # requires age_ok only when security_track is true. | |
| echo "::notice::version track — aged upstream by dependabot.yml's cooldown; the release-age gate does not apply." | |
| echo "age_ok=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| eco="${DEP_ECOSYSTEM,,}" | |
| eco="${eco//[[:space:]]/}" | |
| case "$eco" in | |
| uv|pip) ;; | |
| *) | |
| # NOT a new denial: the ghsa step above queries `ecosystem=pip` only, so every | |
| # non-pip SECURITY PR already routes to manual review today. If that gate is ever made | |
| # ecosystem-aware, wire the matching publish-date source in here at the same time — | |
| # registry.npmjs.org's `.time[version]` for npm, the commit/release date for actions. | |
| # Until then, writing dated code for ecosystems that cannot reach this step would be | |
| # dead code that looks like coverage. | |
| echo "::notice::no publish-date source wired for ecosystem '${eco:-<none>}' — routing to manual review." | |
| echo "age_ok=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| ;; | |
| esac | |
| # `||` outside the substitution, and for the same streaming-jq reason as guardrail #2's. | |
| pairs="$(printf '%s' "$DEPS_JSON" | jq -r '.[] | [.dependencyName, .newVersion] | @tsv' 2>/dev/null)" || pairs="ERR" | |
| if [ "$pairs" = "ERR" ] || [ -z "$pairs" ]; then | |
| echo "::warning::no per-dependency metadata — failing closed (manual review)." | |
| echo "age_ok=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| age_ok=true | |
| now="$(date -u +%s)" | |
| while IFS="$(printf '\t')" read -r raw ver; do | |
| name="${raw,,}" | |
| name="${name//[[:space:]]/}" | |
| if [ -z "$name" ]; then continue; fi | |
| # `newVersion` carries the SAME update_metadata.ts `index === 0` fallback the prevVersion | |
| # guard above cites: a non-first dependency can arrive with an empty string. An unknown | |
| # candidate version means the age question cannot be asked, so DENY. | |
| if [ -z "$ver" ]; then | |
| echo "::warning::no new version for '$name' — failing closed (manual review)." | |
| age_ok=false | |
| break | |
| fi | |
| # PEP 503 normalisation: lowercase, runs of -_. collapse to a single '-'. | |
| name="${name//[-_.]/-}" | |
| while [[ "$name" == *--* ]]; do name="${name//--/-}"; done | |
| # Both values are PR-DERIVED and are about to be pasted into a URL, so validate their | |
| # shape rather than trusting them. | |
| case "$name" in | |
| ""|*[!a-z0-9-]*) | |
| echo "::warning::unexpected package name '$raw' — failing closed (manual review)." | |
| age_ok=false | |
| break | |
| ;; | |
| esac | |
| case "$ver" in | |
| *[!A-Za-z0-9._+!-]*) | |
| echo "::warning::unexpected version '$ver' for '$name' — failing closed (manual review)." | |
| age_ok=false | |
| break | |
| ;; | |
| esac | |
| # `--fail` suppresses the 4xx/5xx body, so the old form happened to work for HTTP errors | |
| # — but a TRUNCATED transfer (curl 18/56) exits non-zero with partial bytes already on | |
| # stdout, and the sentinel would have been appended to a half a JSON document. Outside. | |
| body="$(curl -sS --fail --max-time 20 --retry 2 "https://pypi.org/pypi/${name}/${ver}/json" 2>/dev/null)" || body="ERR" | |
| if [ "$body" = "ERR" ] || [ -z "$body" ]; then | |
| echo "::warning::PyPI lookup failed for '${name}==${ver}' — failing closed (manual review)." | |
| age_ok=false | |
| break | |
| fi | |
| published="$(printf '%s' "$body" | jq -r '[.urls[].upload_time_iso_8601] | sort | .[0] // empty' 2>/dev/null)" || published="ERR" | |
| if [ "$published" = "ERR" ] || [ -z "$published" ] || [ "$published" = "null" ]; then | |
| echo "::warning::no upload timestamp for '${name}==${ver}' — failing closed (manual review)." | |
| age_ok=false | |
| break | |
| fi | |
| published_epoch="$(date -u -d "$published" +%s 2>/dev/null)" || published_epoch="ERR" | |
| case "$published_epoch" in | |
| ""|*[!0-9]*) | |
| echo "::warning::unparseable upload timestamp '$published' for '${name}==${ver}' — failing closed." | |
| age_ok=false | |
| break | |
| ;; | |
| esac | |
| age_hours=$(( (now - published_epoch) / 3600 )) | |
| if [ "$age_hours" -lt 0 ]; then | |
| echo "::warning::'${name}==${ver}' reports a FUTURE upload time (clock skew?) — failing closed." | |
| age_ok=false | |
| break | |
| fi | |
| if [ "$age_hours" -lt "$MIN_RELEASE_AGE_HOURS" ]; then | |
| echo "::warning::'${name}==${ver}' is ${age_hours}h old, under the ${MIN_RELEASE_AGE_HOURS}h minimum — routing to manual review." | |
| age_ok=false | |
| break | |
| fi | |
| echo "::notice::'${name}==${ver}' was published ${age_hours}h ago (>= ${MIN_RELEASE_AGE_HOURS}h)." | |
| done <<< "$pairs" | |
| echo "age_ok=$age_ok" >> "$GITHUB_OUTPUT" | |
| - name: Enable auto-merge for in-scope updates | |
| # Auto-merge only when: (a) EVERY dependency is on its ecosystem's allow row (#3), AND (b) | |
| # for the SECURITY track ONLY, a published advisory is confirmed (#2) AND the candidate | |
| # release has aged (#4), AND (c) the update is an in-scope type (any patch, or a dev-only | |
| # minor). A non-security (version-track) patch keeps its cooldown-aged auto-merge — neither | |
| # advisory_ok nor age_ok is required there, which is why both are emitted false on that path. | |
| if: >- | |
| steps.allowset.outputs.eligible == 'true' && | |
| (steps.ghsa.outputs.security_track != 'true' || | |
| (steps.ghsa.outputs.advisory_ok == 'true' && | |
| steps.age.outputs.age_ok == 'true')) && | |
| (steps.meta.outputs.update-type == 'version-update:semver-patch' || | |
| (steps.meta.outputs.update-type == 'version-update:semver-minor' && | |
| steps.meta.outputs.dependency-type == 'direct:development')) | |
| run: gh pr merge --auto --squash "$PR_URL" | |
| env: | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |