From be394bd6e1423e927f5e33919f633b89644fdbf5 Mon Sep 17 00:00:00 2001 From: hongming-codex-laptop Date: Wed, 13 May 2026 18:41:59 -0700 Subject: [PATCH] fix(ci): collapse review comment refire triggers --- .gitea/scripts/review-refire-status.sh | 81 +++++++++++++ .../scripts/tests/test_sop_checklist_gate.py | 35 +++--- .gitea/scripts/tests/test_sop_tier_refire.sh | 52 ++++++--- .gitea/workflows/qa-review.yml | 22 ++-- .gitea/workflows/review-refire-comments.yml | 109 ++++++++++++++++++ .gitea/workflows/security-review.yml | 11 +- .gitea/workflows/sop-tier-refire.yml | 53 +++------ 7 files changed, 266 insertions(+), 97 deletions(-) create mode 100755 .gitea/scripts/review-refire-status.sh create mode 100644 .gitea/workflows/review-refire-comments.yml diff --git a/.gitea/scripts/review-refire-status.sh b/.gitea/scripts/review-refire-status.sh new file mode 100755 index 00000000..0ec2f605 --- /dev/null +++ b/.gitea/scripts/review-refire-status.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Re-run review-check.sh for a slash-command refire and post the protected +# pull_request status context to the PR head SHA. + +set -euo pipefail + +: "${GITEA_TOKEN:?GITEA_TOKEN required}" +: "${GITEA_HOST:?GITEA_HOST required}" +: "${REPO:?REPO required}" +: "${PR_NUMBER:?PR_NUMBER required}" +: "${TEAM:?TEAM required}" + +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +API="https://${GITEA_HOST}/api/v1" +CONTEXT="${TEAM}-review / approved (pull_request)" +TARGET_URL="https://${GITEA_HOST}/${OWNER}/${NAME}/pulls/${PR_NUMBER}" + +authfile=$(mktemp) +prfile=$(mktemp) +postfile=$(mktemp) +# shellcheck disable=SC2329 # invoked by EXIT trap +cleanup() { + rm -f "$authfile" "$prfile" "$postfile" +} +trap cleanup EXIT + +chmod 600 "$authfile" +printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile" + +code=$(curl -sS -o "$prfile" -w '%{http_code}' -K "$authfile" \ + "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}") +if [ "$code" != "200" ]; then + echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${code}" + head -c 200 "$prfile" >&2 || true + exit 1 +fi + +head_sha=$(jq -r '.head.sha // ""' "$prfile") +state=$(jq -r '.state // ""' "$prfile") +if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then + echo "::error::Could not resolve PR head SHA for PR ${PR_NUMBER}" + exit 1 +fi +if [ "$state" != "open" ]; then + echo "::notice::PR ${PR_NUMBER} is ${state}; ${TEAM}-review refire is a no-op" + exit 0 +fi + +set +e +bash .gitea/scripts/review-check.sh +rc=$? +set -e + +if [ "$rc" -eq 0 ]; then + status_state="success" + description="Refired via /${TEAM}-recheck by ${COMMENT_AUTHOR:-unknown}" +else + status_state="failure" + description="Refired via /${TEAM}-recheck; ${TEAM}-review failed" +fi + +body=$(jq -nc \ + --arg state "$status_state" \ + --arg context "$CONTEXT" \ + --arg description "$description" \ + --arg target_url "$TARGET_URL" \ + '{state:$state, context:$context, description:$description, target_url:$target_url}') + +code=$(curl -sS -o "$postfile" -w '%{http_code}' -X POST \ + -K "$authfile" -H "Content-Type: application/json" \ + -d "$body" \ + "${API}/repos/${OWNER}/${NAME}/statuses/${head_sha}") +if [ "$code" != "200" ] && [ "$code" != "201" ]; then + echo "::error::POST /statuses/${head_sha} returned HTTP ${code}" + head -c 200 "$postfile" >&2 || true + exit 1 +fi + +echo "::notice::posted ${status_state} for context=\"${CONTEXT}\" on sha=${head_sha}" +exit "$rc" diff --git a/.gitea/scripts/tests/test_sop_checklist_gate.py b/.gitea/scripts/tests/test_sop_checklist_gate.py index 7622c79a..47ae4f23 100644 --- a/.gitea/scripts/tests/test_sop_checklist_gate.py +++ b/.gitea/scripts/tests/test_sop_checklist_gate.py @@ -134,18 +134,22 @@ class TestParseDirectives(unittest.TestCase): def setUp(self): self.aliases = _numeric_aliases() + def parse_ack_revoke(self, body): + directives, na_directives = sop.parse_directives(body, self.aliases) + self.assertEqual(na_directives, []) + return directives + def test_simple_ack(self): - d = sop.parse_directives("/sop-ack comprehensive-testing", self.aliases) + d = self.parse_ack_revoke("/sop-ack comprehensive-testing") self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")]) def test_simple_revoke(self): - d = sop.parse_directives("/sop-revoke staging-smoke", self.aliases) + d = self.parse_ack_revoke("/sop-revoke staging-smoke") self.assertEqual(d, [("sop-revoke", "staging-smoke", "")]) def test_ack_with_note(self): - d = sop.parse_directives( - "/sop-ack comprehensive-testing LGTM the test covers all edge cases", - self.aliases, + d = self.parse_ack_revoke( + "/sop-ack comprehensive-testing LGTM the test covers all edge cases" ) self.assertEqual(len(d), 1) self.assertEqual(d[0][0], "sop-ack") @@ -153,13 +157,12 @@ class TestParseDirectives(unittest.TestCase): self.assertIn("LGTM", d[0][2]) def test_numeric_shorthand(self): - d = sop.parse_directives("/sop-ack 1", self.aliases) + d = self.parse_ack_revoke("/sop-ack 1") self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")]) def test_revoke_with_reason(self): - d = sop.parse_directives( - "/sop-revoke comprehensive-testing realized the e2e was mocking the DB", - self.aliases, + d = self.parse_ack_revoke( + "/sop-revoke comprehensive-testing realized the e2e was mocking the DB" ) self.assertEqual(d[0][0], "sop-revoke") self.assertEqual(d[0][1], "comprehensive-testing") @@ -171,7 +174,7 @@ class TestParseDirectives(unittest.TestCase): "/sop-ack comprehensive-testing\n" "Will follow up on the doc nit separately." ) - d = sop.parse_directives(body, self.aliases) + d = self.parse_ack_revoke(body) self.assertEqual(len(d), 1) self.assertEqual(d[0][1], "comprehensive-testing") @@ -180,7 +183,7 @@ class TestParseDirectives(unittest.TestCase): "/sop-ack comprehensive-testing\n" "/sop-ack local-postgres-e2e\n" ) - d = sop.parse_directives(body, self.aliases) + d = self.parse_ack_revoke(body) self.assertEqual(len(d), 2) slugs = {x[1] for x in d} self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"}) @@ -189,21 +192,21 @@ class TestParseDirectives(unittest.TestCase): # A directive embedded mid-line is not honored (prevents review # comments like "to /sop-ack you need..." from acting as acks). body = "If you want to /sop-ack comprehensive-testing reply in this thread" - d = sop.parse_directives(body, self.aliases) + d = self.parse_ack_revoke(body) self.assertEqual(d, []) def test_leading_whitespace_allowed(self): body = " /sop-ack comprehensive-testing" - d = sop.parse_directives(body, self.aliases) + d = self.parse_ack_revoke(body) self.assertEqual(len(d), 1) def test_empty_body(self): - self.assertEqual(sop.parse_directives("", self.aliases), []) - self.assertEqual(sop.parse_directives(None, self.aliases), []) + self.assertEqual(sop.parse_directives("", self.aliases), ([], [])) + self.assertEqual(sop.parse_directives(None, self.aliases), ([], [])) def test_normalization_applied(self): # /sop-ack Comprehensive_Testing → canonical comprehensive-testing - d = sop.parse_directives("/sop-ack Comprehensive_Testing", self.aliases) + d = self.parse_ack_revoke("/sop-ack Comprehensive_Testing") self.assertEqual(d[0][1], "comprehensive-testing") diff --git a/.gitea/scripts/tests/test_sop_tier_refire.sh b/.gitea/scripts/tests/test_sop_tier_refire.sh index 8cf8ba51..fb8a40a7 100755 --- a/.gitea/scripts/tests/test_sop_tier_refire.sh +++ b/.gitea/scripts/tests/test_sop_tier_refire.sh @@ -32,6 +32,7 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)" WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)" WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml" +DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml" SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh" PASS=0 @@ -87,6 +88,7 @@ assert_file_exists() { echo echo "== existence ==" assert_file_exists "workflow file exists" "$WORKFLOW" +assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW" assert_file_exists "script file exists" "$SCRIPT" if [ "$FAIL" -gt 0 ]; then echo @@ -104,29 +106,43 @@ echo "== T6/T7 workflow yaml ==" PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$WORKFLOW" 2>&1 || true) assert_eq "T7 workflow parses as YAML" "ok" "$PARSE_OUT" -# Three required gates in the `if:` expression +# The old per-workflow issue_comment listener caused queue storms because +# Gitea queues jobs before evaluating job-level `if:`. The script remains, +# but comment-triggered refires route through the single dispatcher. WORKFLOW_CONTENT=$(cat "$WORKFLOW") -assert_contains "T6a workflow if: contains author_association gate" \ - "github.event.comment.author_association" "$WORKFLOW_CONTENT" -assert_contains "T6b workflow if: gates on MEMBER/OWNER/COLLABORATOR" \ - '["MEMBER","OWNER","COLLABORATOR"]' "$WORKFLOW_CONTENT" -assert_contains "T6c workflow if: contains slash-command trigger" \ - "/refire-tier-check" "$WORKFLOW_CONTENT" -assert_contains "T6d workflow if: gates on PR-not-issue" \ - "github.event.issue.pull_request" "$WORKFLOW_CONTENT" -assert_contains "T6e workflow listens on issue_comment" \ - "issue_comment" "$WORKFLOW_CONTENT" -assert_contains "T6f workflow requests statuses:write permission" \ - "statuses: write" "$WORKFLOW_CONTENT" -# Does NOT check out PR HEAD (security) -if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then - echo " FAIL T6g workflow MUST NOT check out PR head (security)" +if printf '%s' "$WORKFLOW_CONTENT" | grep -q '^ issue_comment:'; then + echo " FAIL T6a manual fallback workflow must not listen on issue_comment" FAIL=$((FAIL + 1)) - FAILED_TESTS="${FAILED_TESTS} T6g" + FAILED_TESTS="${FAILED_TESTS} T6a" else - echo " PASS T6g workflow does not check out PR head" + echo " PASS T6a manual fallback workflow does not listen on issue_comment" PASS=$((PASS + 1)) fi +assert_contains "T6b workflow exposes workflow_dispatch" \ + "workflow_dispatch" "$WORKFLOW_CONTENT" +assert_contains "T6c workflow documents unsupported manual inputs" \ + "workflow_dispatch inputs" "$WORKFLOW_CONTENT" +# Does NOT check out PR HEAD (security) +if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then + echo " FAIL T6d workflow MUST NOT check out PR head (security)" + FAIL=$((FAIL + 1)) + FAILED_TESTS="${FAILED_TESTS} T6d" +else + echo " PASS T6d workflow does not check out PR head" + PASS=$((PASS + 1)) +fi + +DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true) +assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT" +DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW") +assert_contains "T6f dispatcher listens on issue_comment" \ + "issue_comment" "$DISPATCH_CONTENT" +assert_contains "T6g dispatcher handles /qa-recheck" \ + "/qa-recheck" "$DISPATCH_CONTENT" +assert_contains "T6h dispatcher handles /security-recheck" \ + "/security-recheck" "$DISPATCH_CONTENT" +assert_contains "T6i dispatcher handles /refire-tier-check" \ + "/refire-tier-check" "$DISPATCH_CONTENT" # T1-T5 — script behavior against a local Gitea-fixture echo diff --git a/.gitea/workflows/qa-review.yml b/.gitea/workflows/qa-review.yml index 5fc0f5bf..13f610dc 100644 --- a/.gitea/workflows/qa-review.yml +++ b/.gitea/workflows/qa-review.yml @@ -9,10 +9,10 @@ # Triggers on: # - `pull_request_target`: opened, synchronize, reopened # → initial status posts when PR opens / re-pushes -# - `issue_comment`: /qa-recheck slash-command on the PR -# → manual re-fire after a QA reviewer clicks APPROVE -# (Gitea 1.22.6 doesn't re-fire on pull_request_review, per -# go-gitea/gitea#33700 + feedback_pull_request_review_no_refire) +# - comment refires are handled by `review-refire-comments.yml` +# → a single issue_comment dispatcher prevents every SOP/review +# comment from enqueueing separate qa/security/tier jobs on +# Gitea 1.22.6 before job-level `if:` can skip them. # Workflow name = `qa-review` ; job name = `approved`. # The job's own pass/fail conclusion publishes the status context # `qa-review / approved ()` — NO `POST /statuses` call → NO @@ -85,8 +85,6 @@ name: qa-review on: pull_request_target: types: [opened, synchronize, reopened] - issue_comment: - types: [created] permissions: contents: read @@ -97,16 +95,10 @@ jobs: approved: # Gate the job: # - On pull_request_target events: always run. - # - On issue_comment events: only when it's a PR comment and the body - # contains the slash-command. NO privilege gate at the step level - # (RFC#324 v1.3 §A1.1): a non-collaborator's /qa-recheck is fine - # because the eval is read-only and idempotent — re-running it - # just re-confirms whether a real team-member APPROVE exists. + # Comment-triggered refires live in review-refire-comments.yml. Keeping + # this workflow PR-only avoids comment-triggered queue storms. if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && - github.event.issue.pull_request != null && - startsWith(github.event.comment.body, '/qa-recheck')) + github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate) diff --git a/.gitea/workflows/review-refire-comments.yml b/.gitea/workflows/review-refire-comments.yml new file mode 100644 index 00000000..97eb1371 --- /dev/null +++ b/.gitea/workflows/review-refire-comments.yml @@ -0,0 +1,109 @@ +# Consolidated comment dispatcher for manual review/tier refires. +# +# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before +# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when +# qa-review, security-review, sop-checklist-gate, and sop-tier-refire all +# listened to comments. This workflow is the single non-SOP comment subscriber: +# ordinary comments no-op quickly; slash commands post the required status +# contexts to the PR head SHA. + +name: review-refire-comments + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Classify comment + id: classify + env: + COMMENT_BODY: ${{ github.event.comment.body }} + IS_PR: ${{ github.event.issue.pull_request != null }} + run: | + set -euo pipefail + { + echo "run_qa=false" + echo "run_security=false" + echo "run_tier=false" + } >> "$GITHUB_OUTPUT" + if [ "$IS_PR" != "true" ]; then + echo "::notice::not a PR comment; no-op" + exit 0 + fi + first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p') + case "$first_line" in + /qa-recheck*) + echo "run_qa=true" >> "$GITHUB_OUTPUT" + ;; + /security-recheck*) + echo "run_security=true" >> "$GITHUB_OUTPUT" + ;; + /refire-tier-check*) + echo "run_tier=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::notice::no supported review refire slash command; no-op" + ;; + esac + + - name: Check out BASE ref for trusted scripts + if: | + steps.classify.outputs.run_qa == 'true' || + steps.classify.outputs.run_security == 'true' || + steps.classify.outputs.run_tier == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Refire qa-review status + if: steps.classify.outputs.run_qa == 'true' + env: + GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }} + GITEA_HOST: git.moleculesai.app + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TEAM: qa + TEAM_ID: '20' + REVIEW_CHECK_DEBUG: '0' + REVIEW_CHECK_STRICT: '0' + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + run: | + set -euo pipefail + .gitea/scripts/review-refire-status.sh + + - name: Refire security-review status + if: steps.classify.outputs.run_security == 'true' + env: + GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }} + GITEA_HOST: git.moleculesai.app + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TEAM: security + TEAM_ID: '21' + REVIEW_CHECK_DEBUG: '0' + REVIEW_CHECK_STRICT: '0' + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + run: | + set -euo pipefail + .gitea/scripts/review-refire-status.sh + + - name: Refire sop-tier-check status + if: steps.classify.outputs.run_tier == 'true' + env: + GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} + GITEA_HOST: git.moleculesai.app + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + SOP_DEBUG: '0' + run: bash .gitea/scripts/sop-tier-refire.sh diff --git a/.gitea/workflows/security-review.yml b/.gitea/workflows/security-review.yml index 3013fe8a..b882a742 100644 --- a/.gitea/workflows/security-review.yml +++ b/.gitea/workflows/security-review.yml @@ -12,8 +12,6 @@ name: security-review on: pull_request_target: types: [opened, synchronize, reopened] - issue_comment: - types: [created] permissions: contents: read @@ -22,13 +20,10 @@ permissions: jobs: # bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required. approved: - # See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational - # log only, NOT a gate) / A4 / A5 design rationale. + # Comment-triggered refires live in review-refire-comments.yml. Keeping + # this workflow PR-only avoids comment-triggered queue storms. if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && - github.event.issue.pull_request != null && - startsWith(github.event.comment.body, '/security-recheck')) + github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate) diff --git a/.gitea/workflows/sop-tier-refire.yml b/.gitea/workflows/sop-tier-refire.yml index a2a65382..aaaaad88 100644 --- a/.gitea/workflows/sop-tier-refire.yml +++ b/.gitea/workflows/sop-tier-refire.yml @@ -1,4 +1,4 @@ -# sop-tier-refire — issue_comment-triggered refire of sop-tier-check. +# sop-tier-refire — manual fallback for sop-tier-check refire. # # Closes internal#292. Gitea 1.22.6 doesn't refire workflows on the # `pull_request_review` event (go-gitea/gitea#33700); the `sop-tier-check` @@ -8,12 +8,12 @@ # to merge is the admin force-merge path (audited via `audit-force-merge` # but the audit trail keeps growing; see `feedback_never_admin_merge_bypass`). # -# Workaround pattern from `feedback_pull_request_review_no_refire`: -# `issue_comment` events DO fire reliably on 1.22.6. When a repo -# MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, this -# workflow re-runs the sop-tier-check logic and POSTs the resulting -# status to the PR head SHA directly. No empty commit, no git history -# bloat, no cascade re-fire of every other workflow on the PR. +# Comment-triggered refires now live in `review-refire-comments.yml`. Gitea +# queues issue_comment workflows before evaluating job-level `if:`, so having +# qa-review, security-review, sop-checklist, and sop-tier-refire all subscribe +# to every comment caused queue storms on SOP-heavy PRs. This workflow is a +# non-automatic breadcrumb only; Gitea 1.22.6 does not support +# workflow_dispatch inputs, so real refires must use `/refire-tier-check`. # # SECURITY MODEL: # @@ -37,43 +37,16 @@ # Rate-limit: a 1s pre-sleep + a "skip if status posted in last 30s" # guard prevents comment-spam from thrashing the status. See the script. -name: sop-tier-check refire (issue_comment) +name: sop-tier-check refire (manual) on: - issue_comment: - types: [created] + workflow_dispatch: jobs: refire: - # Three gates, all required: - # - comment is on a PR (not a plain issue) - # - commenter is MEMBER, OWNER, or COLLABORATOR - # - comment body contains the slash-command trigger - if: | - github.event.issue.pull_request != null && - contains(fromJson('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) && - contains(github.event.comment.body, '/refire-tier-check') runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - statuses: write steps: - - name: Check out base branch (for the script) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Load the script from the default branch (main), matching the - # sop-tier-check.yml security model. - ref: ${{ github.event.repository.default_branch }} - - name: Re-evaluate sop-tier-check and POST status - env: - # Same org-level secret sop-tier-check.yml + audit-force-merge.yml use. - # Fallback to GITHUB_TOKEN with a clear error if missing. - GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} - GITEA_HOST: git.moleculesai.app - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.issue.number }} - COMMENT_AUTHOR: ${{ github.event.comment.user.login }} - # Set to '1' for diagnostic per-API-call output. Off by default. - SOP_DEBUG: '0' - run: bash .gitea/scripts/sop-tier-refire.sh + - name: Explain supported refire path + run: | + echo "::error::Gitea 1.22.6 does not support workflow_dispatch inputs here; comment /refire-tier-check on the PR instead." + exit 1 -- 2.45.2