diff --git a/.github/actions/dismiss-dev-only-dependabot-alerts/action.yml b/.github/actions/dismiss-dev-only-dependabot-alerts/action.yml
new file mode 100644
index 0000000..bc8c395
--- /dev/null
+++ b/.github/actions/dismiss-dev-only-dependabot-alerts/action.yml
@@ -0,0 +1,331 @@
+name: Dismiss dev-only Dependabot alerts
+description: |
+ Runs a Gradle-based dependency analysis on the already-checked-out caller
+ repository and auto-dismisses Dependabot alerts whose every reachable
+ occurrence is in a dev-only (non-production) configuration.
+
+ This composite action is the engine. It assumes the caller repository is
+ ALREADY checked out in the workspace and does NOT check it out itself, nor
+ does it source any secrets — that lets callers run their own pre-steps
+ (install tools, fetch secrets from any backend) between checkout and this
+ action. The thin reusable workflow in this repo wraps it for the common
+ case that needs no custom setup.
+
+inputs:
+ client-id:
+ required: true
+ description: |
+ App ID (or Client ID) of the `infinum-dependabot-dismisser` GitHub App.
+ Passed through to `actions/create-github-app-token@v3` via its
+ `client-id` input, which accepts either form.
+ app-private-key:
+ required: true
+ description: |
+ PEM private key of the `infinum-dependabot-dismisser` GitHub App. Used
+ to mint an installation access token at runtime with
+ `Dependabot alerts: read/write` on the caller repo. The default
+ GITHUB_TOKEN cannot access the Dependabot Alerts REST API. Composite
+ actions have no `secrets:` block, so this is an input; pass it from a
+ secret and GitHub still masks it in logs.
+ dry-run:
+ default: "true"
+ description: "When true, decisions are written to the job summary but no alerts are dismissed."
+ java-version:
+ default: "17"
+ description: "JDK version for the Gradle run. Default 17; set to 11 for projects on AGP 7.x or older."
+ trusted-sources:
+ default: ""
+ description: |
+ Additional Maven-group glob patterns (one per line) to trust beyond the
+ built-in defaults (com.android.*, androidx.*, org.jetbrains.kotlin.*,
+ org.jetbrains.*, com.google.*). Caller lines append to the defaults.
+ Patterns use `group.with.dots.*` or exact `group:artifact`.
+ trusted-source-scopes:
+ default: "buildscript"
+ description: |
+ Scopes (one per line) where the trusted-source rule applies. Allowed:
+ buildscript, production, codegen-main. Default: buildscript only.
+ production-build-types:
+ default: "release"
+ description: |
+ Build type names (one per line, lowercase) considered production-shipping.
+ Used to identify variant prefixes whose CompileClasspath/RuntimeClasspath
+ configurations reach the published APK.
+ production-variants:
+ default: ""
+ description: |
+ Exact variant names (one per line) treated as production-shipping. When
+ non-empty this overrides `production-build-types` — only these variant
+ prefixes count as production.
+ currency-threshold:
+ default: "latest"
+ description: |
+ How close to the latest Maven release a trusted top-level must be.
+ Allowed: `latest` (exact match) or `same-minor` (any patch within
+ latest's major.minor).
+
+runs:
+ using: composite
+ steps:
+ - name: Mint GitHub App installation token
+ id: app-token
+ uses: actions/create-github-app-token@v3
+ with:
+ client-id: ${{ inputs.client-id }}
+ private-key: ${{ inputs.app-private-key }}
+ owner: ${{ github.repository_owner }}
+
+ # Fetch alerts once and cache them for later steps. This also self-gates
+ # the heavy work: `has-alerts` is consumed by the `if:` on every step
+ # below, so direct callers skip the Gradle analysis when nothing is open
+ # (the reusable-workflow wrapper additionally gates at the job level).
+ - name: Fetch open Dependabot alerts
+ id: alerts
+ shell: bash
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/dismiss-bot
+ gh api "repos/${REPO}/dependabot/alerts?state=open&per_page=100" --paginate \
+ > /tmp/dismiss-bot/alerts.json
+ COUNT=$(jq 'length' /tmp/dismiss-bot/alerts.json)
+ if [ "${COUNT}" -gt 0 ]; then
+ echo "has-alerts=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has-alerts=false" >> "$GITHUB_OUTPUT"
+ {
+ echo "## Dismiss dev-only Dependabot alerts"
+ echo ""
+ echo "- Open alerts: \`0\`"
+ echo ""
+ echo "_No open Dependabot alerts. Skipping Gradle analysis._"
+ } >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Resolve scripts directory
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ # This action lives at `.github/actions/dismiss-dev-only-dependabot-alerts`,
+ # so the pipeline scripts at the repo root are three levels up. Resolving
+ # them via $GITHUB_ACTION_PATH means we need no separate tooling checkout.
+ run: echo "SCRIPTS_DIR=$(cd "$GITHUB_ACTION_PATH/../../../scripts" && pwd)" >> "$GITHUB_ENV"
+
+ # Some caller repos store gradle-wrapper.jar in Git LFS (e.g. an
+ # `*.jar filter=lfs` rule). The caller's checkout leaves it as a pointer
+ # file, which the JVM rejects as "Invalid or corrupt jarfile". Pull only
+ # that one object so `./gradlew` can launch. No-op on repos without LFS.
+ - name: Materialize Gradle wrapper from LFS
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ run: git lfs pull --include="gradle/wrapper/gradle-wrapper.jar"
+
+ - name: Set up JDK
+ if: steps.alerts.outputs.has-alerts == 'true'
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: ${{ inputs.java-version }}
+
+ - name: Set up Android SDK
+ if: steps.alerts.outputs.has-alerts == 'true'
+ uses: android-actions/setup-android@v4
+
+ - name: Set up Python
+ if: steps.alerts.outputs.has-alerts == 'true'
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+
+ - name: Build dismissal config
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ env:
+ TRUSTED_SOURCES_EXTRA: ${{ inputs.trusted-sources }}
+ TRUSTED_SOURCE_SCOPES: ${{ inputs.trusted-source-scopes }}
+ PRODUCTION_BUILD_TYPES: ${{ inputs.production-build-types }}
+ PRODUCTION_VARIANTS: ${{ inputs.production-variants }}
+ CURRENCY_THRESHOLD: ${{ inputs.currency-threshold }}
+ run: |
+ set -euo pipefail
+ mkdir -p build/dependabot-tools
+ python3 - <<'PY'
+ import json, os, sys
+
+ def parse_multiline(name):
+ raw = os.environ.get(name, "")
+ return [line.strip() for line in raw.splitlines() if line.strip()]
+
+ defaults = [
+ "com.android.*",
+ "androidx.*",
+ "org.jetbrains.kotlin.*",
+ "org.jetbrains.*",
+ "com.google.*",
+ ]
+ extra = parse_multiline("TRUSTED_SOURCES_EXTRA")
+ trusted_sources = defaults + [p for p in extra if p not in defaults]
+
+ scopes = parse_multiline("TRUSTED_SOURCE_SCOPES")
+ allowed_scopes = {"buildscript", "production", "codegen-main"}
+ bad = [s for s in scopes if s not in allowed_scopes]
+ if bad:
+ print(f"::error::Invalid trusted-source-scopes value(s): {bad}. "
+ f"Allowed: {sorted(allowed_scopes)}", file=sys.stderr)
+ sys.exit(1)
+
+ threshold = os.environ.get("CURRENCY_THRESHOLD", "").strip()
+ if threshold not in {"latest", "same-minor"}:
+ print(f"::error::Invalid currency-threshold: {threshold!r}. "
+ f"Allowed: 'latest', 'same-minor'", file=sys.stderr)
+ sys.exit(1)
+
+ config = {
+ "trusted_sources": trusted_sources,
+ "trusted_source_scopes": scopes,
+ "production_build_types": parse_multiline("PRODUCTION_BUILD_TYPES"),
+ "production_variants": parse_multiline("PRODUCTION_VARIANTS"),
+ "currency_threshold": threshold,
+ }
+ with open("build/dependabot-tools/dismiss-config.json", "w") as f:
+ json.dump(config, f, indent=2)
+ print(json.dumps(config, indent=2))
+ PY
+
+ - name: Gather per-module dependency output
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ run: |
+ chmod +x ./gradlew
+ chmod +x "$SCRIPTS_DIR/gather-deps.sh"
+ "$SCRIPTS_DIR/gather-deps.sh" .
+
+ - name: Parse dependency text into JSON
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ run: |
+ # `--included-builds` activates composite-aware parsing: every project
+ # is tagged with its build, and reachability is computed via BFS from
+ # the root build's modules. The file is produced by gather-deps.sh.
+ INCLUDED_BUILDS_ARG=""
+ if [ -s build/dependabot-tools/included-builds.txt ]; then
+ INCLUDED_BUILDS_ARG="--included-builds build/dependabot-tools/included-builds.txt"
+ fi
+ python3 "$SCRIPTS_DIR/parse-gradle-deps.py" \
+ --input build/dependabot-tools/gather-deps.txt \
+ --output build/dependabot-tools/dep-config-map.json \
+ $INCLUDED_BUILDS_ARG
+
+ - name: Build trusted-source currency map
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ run: |
+ python3 "$SCRIPTS_DIR/build-trusted-currency.py" \
+ --dep-map build/dependabot-tools/dep-config-map.json \
+ --alerts /tmp/dismiss-bot/alerts.json \
+ --config build/dependabot-tools/dismiss-config.json \
+ --output build/dependabot-tools/trusted-currency.json
+
+ - name: Categorize and dismiss alerts
+ if: steps.alerts.outputs.has-alerts == 'true'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ REPO: ${{ github.repository }}
+ run: |
+ set -euo pipefail
+ DEP_MAP=build/dependabot-tools/dep-config-map.json
+ CONFIG=build/dependabot-tools/dismiss-config.json
+ CURRENCY=build/dependabot-tools/trusted-currency.json
+
+ # Pretty values, each entry wrapped in backticks for the table.
+ TRUSTED_SOURCES_TICKED=$(jq -r '.trusted_sources | map("`" + . + "`") | join(", ")' "$CONFIG")
+ SCOPES_TICKED=$(jq -r '.trusted_source_scopes | map("`" + . + "`") | join(", ")' "$CONFIG")
+ PROD_BT_TICKED=$(jq -r '.production_build_types | map("`" + . + "`") | join(", ")' "$CONFIG")
+ PROD_V_TICKED=$(jq -r '.production_variants | map("`" + . + "`") | join(", ")' "$CONFIG")
+ THRESHOLD=$(jq -r '.currency_threshold' "$CONFIG")
+ PROD_V_DISPLAY=${PROD_V_TICKED:-_none_}
+
+ {
+ echo "## Configuration"
+ echo ""
+ echo "| Setting | Value | What it controls |"
+ echo "| --- | --- | --- |"
+ echo "| Trusted sources | ${TRUSTED_SOURCES_TICKED} | Maven groups whose transitives can be dismissed when the top-level is current. Anything else that pulls in a vulnerable dep is treated as unsafe. |"
+ echo "| Trusted-source scopes | ${SCOPES_TICKED} | Where the trusted-source rule applies. Other scopes (\`production\`, \`codegen-main\`) stay closed unless explicitly added. |"
+ echo "| Production build types | ${PROD_BT_TICKED} | Build types whose \`CompileClasspath\`/\`RuntimeClasspath\` configs count as production-shipping. |"
+ echo "| Production variants (override) | ${PROD_V_DISPLAY} | When non-empty, replaces \`production-build-types\` with an exact variant list. |"
+ echo "| Currency threshold | \`${THRESHOLD}\` | How close to the latest Maven release a trusted top-level must be. \`latest\` = exact match; \`same-minor\` = same \`major.minor\`. |"
+ echo "| Dry run | \`${DRY_RUN}\` | When \`true\`, decisions are logged but no alerts are dismissed. |"
+ echo ""
+ echo "## Decisions"
+ echo ""
+ echo "An alert is **dismissed** only when every reachable occurrence is in a safe configuration:"
+ echo ""
+ echo "- **Test source sets** (\`test*\`, \`*AndroidTest*\`, \`*UnitTest*\`)"
+ echo "- **Non-production variants** (build-type suffix not in \`production-build-types\`)"
+ echo "- **Build tooling** (lint checks, Detekt, Jacoco, Dokka, etc.)"
+ echo "- **Trusted-source transitives** in opted-in scopes, when the trusted top-level is current"
+ echo ""
+ echo "Otherwise the verdict is **skipped** — the alert stays open for human review. Occurrences in unreachable included-build modules are ignored. Click any alert number below to open it on GitHub."
+ echo ""
+ echo "| Alert | Package | Severity | Decision | Reason |"
+ echo "| ----- | ------- | -------- | -------- | ------ |"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ while read -r ALERT; do
+ NUM=$(printf "%s" "${ALERT}" | jq -r '.number')
+ PKG=$(printf "%s" "${ALERT}" | jq -r '.dependency.package.name')
+ SEV=$(printf "%s" "${ALERT}" | jq -r '.security_advisory.severity')
+
+ RESULT=$(printf "%s" "${ALERT}" | python3 "$SCRIPTS_DIR/categorize-alert.py" \
+ --dep-map "${DEP_MAP}" \
+ --config "${CONFIG}" \
+ --currency-map "${CURRENCY}")
+
+ # categorize-alert.py emits plain text. The summary cell adds
+ # markdown for visual hierarchy; the dismissal comment uses the
+ # plain text verbatim (after truncation + run-URL append by the
+ # helper).
+ DECISION=$(printf "%s" "${RESULT}" | cut -f1)
+ REASON=$(printf "%s" "${RESULT}" | cut -f2)
+ DETAIL=$(printf "%s" "${RESULT}" | cut -f3-)
+
+ case "${DECISION}" in
+ dismiss) MARKER="✅ **Dismissed**" ;;
+ skip) MARKER="❌ **Skipped**" ;;
+ *) MARKER="${DECISION}" ;;
+ esac
+
+ ALERT_LINK="[#${NUM}](https://github.com/${REPO}/security/dependabot/${NUM})"
+
+ # Markdown-only formatting for the summary cell: bold the leading
+ # category clause (everything before the first " — "), and wrap
+ # comma-separated items in the configs detail in inline-code.
+ REASON_MD=$(printf "%s" "${REASON}" | sed -E 's/^([^—]+)( — )/**\1**\2/')
+ if [ -n "${DETAIL}" ]; then
+ # Wrap each item after "Configs: " in backticks.
+ DETAIL_MD=$(printf "%s" "${DETAIL}" | sed -E 's/Configs: /Configs: `/; s/, \+/`, +/; s/, ([^,+]+)/`, `\1/g; s/([^`])$/\1`/')
+ REASON_CELL="${REASON_MD}
${DETAIL_MD}"
+ else
+ REASON_CELL="${REASON_MD}"
+ fi
+
+ printf "| %s | %s | %s | %s | %s |\n" \
+ "${ALERT_LINK}" "${PKG}" "${SEV}" "${MARKER}" "${REASON_CELL}" >> "$GITHUB_STEP_SUMMARY"
+
+ if [ "${DECISION}" = "dismiss" ] && [ "${DRY_RUN}" != "true" ]; then
+ # The Dependabot Alerts API caps `dismissed_comment` at 280
+ # unicode characters. The helper truncates the body if needed
+ # and appends a link to this run for the audit trail.
+ RUN_URL="https://github.com/${REPO}/actions/runs/${GITHUB_RUN_ID}"
+ DISMISS_COMMENT=$(python3 "$SCRIPTS_DIR/format-dismissal-comment.py" \
+ "${REASON}" "${DETAIL}" "${RUN_URL}")
+ gh api -X PATCH "repos/${REPO}/dependabot/alerts/${NUM}" \
+ -f state=dismissed \
+ -f dismissed_reason=not_used \
+ -f dismissed_comment="${DISMISS_COMMENT}" >/dev/null
+ fi
+ done < <(jq -c '.[]' /tmp/dismiss-bot/alerts.json)
diff --git a/.github/workflows/dismiss-dev-only-dependabot-alerts.yml b/.github/workflows/dismiss-dev-only-dependabot-alerts.yml
index 80f5e6c..cdba252 100644
--- a/.github/workflows/dismiss-dev-only-dependabot-alerts.yml
+++ b/.github/workflows/dismiss-dev-only-dependabot-alerts.yml
@@ -120,6 +120,11 @@ jobs:
permissions:
contents: read
steps:
+ # Mint a token for the checkout below. Submodules are other infinum
+ # private repos, so the default GITHUB_TOKEN cannot fetch them; the app
+ # token can. The composite action mints its own token for the Dependabot
+ # Alerts API — this double-mint is cheap and keeps the action
+ # self-contained for callers that invoke it directly.
- name: Mint GitHub App installation token
id: app-token
uses: actions/create-github-app-token@v3
@@ -128,240 +133,26 @@ jobs:
private-key: ${{ secrets.app-private-key }}
owner: ${{ github.repository_owner }}
- - name: Mint tooling repository token
- id: tooling-token
- uses: actions/create-github-app-token@v3
- with:
- client-id: ${{ inputs.client-id }}
- private-key: ${{ secrets.app-private-key }}
- owner: infinum
- repositories: android-github-actions-workflows
-
- - name: Fetch open Dependabot alerts (cached for later steps)
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- REPO: ${{ github.repository }}
- run: |
- set -euo pipefail
- mkdir -p /tmp/dismiss-bot
- gh api "repos/${REPO}/dependabot/alerts?state=open&per_page=100" --paginate \
- > /tmp/dismiss-bot/alerts.json
-
- name: Checkout caller repository
uses: actions/checkout@v6
with:
token: ${{ steps.app-token.outputs.token }}
submodules: recursive
# lfs left at default (false): avoid bulk-downloading every LFS
- # object. Repos that LFS-track the wrapper get it via the next step.
-
- # Some caller repos store gradle-wrapper.jar in Git LFS (e.g. an
- # `*.jar filter=lfs` rule). The checkout above leaves it as a pointer
- # file, which the JVM rejects as "Invalid or corrupt jarfile". Pull only
- # that one object so `./gradlew` can launch. No-op on repos without LFS.
- - name: Materialize Gradle wrapper from LFS
- run: git lfs pull --include="gradle/wrapper/gradle-wrapper.jar"
+ # object. The action materializes only the Gradle wrapper jar.
- - name: Set up JDK
- uses: actions/setup-java@v5
+ # The composite action is the single source of truth for the analysis
+ # pipeline. It assumes the caller repo is already checked out (above) and
+ # runs entirely against this workspace.
+ - name: Run dev-only Dependabot dismisser
+ uses: infinum/android-github-actions-workflows/.github/actions/dismiss-dev-only-dependabot-alerts@main
with:
- distribution: temurin
+ client-id: ${{ inputs.client-id }}
+ app-private-key: ${{ secrets.app-private-key }}
+ dry-run: ${{ inputs.dry-run }}
java-version: ${{ inputs.java-version }}
-
- - name: Set up Android SDK
- uses: android-actions/setup-android@v4
-
- - name: Set up Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.11"
-
- - name: Checkout tooling scripts
- uses: actions/checkout@v6
- with:
- repository: infinum/android-github-actions-workflows
- ref: main
- path: .dismiss-bot
- token: ${{ steps.tooling-token.outputs.token }}
-
- - name: Build dismissal config
- env:
- TRUSTED_SOURCES_EXTRA: ${{ inputs.trusted-sources }}
- TRUSTED_SOURCE_SCOPES: ${{ inputs.trusted-source-scopes }}
- PRODUCTION_BUILD_TYPES: ${{ inputs.production-build-types }}
- PRODUCTION_VARIANTS: ${{ inputs.production-variants }}
- CURRENCY_THRESHOLD: ${{ inputs.currency-threshold }}
- run: |
- set -euo pipefail
- mkdir -p build/dependabot-tools
- python3 - <<'PY'
- import json, os, sys
-
- def parse_multiline(name):
- raw = os.environ.get(name, "")
- return [line.strip() for line in raw.splitlines() if line.strip()]
-
- defaults = [
- "com.android.*",
- "androidx.*",
- "org.jetbrains.kotlin.*",
- "org.jetbrains.*",
- "com.google.*",
- ]
- extra = parse_multiline("TRUSTED_SOURCES_EXTRA")
- trusted_sources = defaults + [p for p in extra if p not in defaults]
-
- scopes = parse_multiline("TRUSTED_SOURCE_SCOPES")
- allowed_scopes = {"buildscript", "production", "codegen-main"}
- bad = [s for s in scopes if s not in allowed_scopes]
- if bad:
- print(f"::error::Invalid trusted-source-scopes value(s): {bad}. "
- f"Allowed: {sorted(allowed_scopes)}", file=sys.stderr)
- sys.exit(1)
-
- threshold = os.environ.get("CURRENCY_THRESHOLD", "").strip()
- if threshold not in {"latest", "same-minor"}:
- print(f"::error::Invalid currency-threshold: {threshold!r}. "
- f"Allowed: 'latest', 'same-minor'", file=sys.stderr)
- sys.exit(1)
-
- config = {
- "trusted_sources": trusted_sources,
- "trusted_source_scopes": scopes,
- "production_build_types": parse_multiline("PRODUCTION_BUILD_TYPES"),
- "production_variants": parse_multiline("PRODUCTION_VARIANTS"),
- "currency_threshold": threshold,
- }
- with open("build/dependabot-tools/dismiss-config.json", "w") as f:
- json.dump(config, f, indent=2)
- print(json.dumps(config, indent=2))
- PY
-
- - name: Gather per-module dependency output
- run: |
- chmod +x ./gradlew
- chmod +x .dismiss-bot/scripts/gather-deps.sh
- .dismiss-bot/scripts/gather-deps.sh .
-
- - name: Parse dependency text into JSON
- run: |
- # `--included-builds` activates composite-aware parsing: every project
- # is tagged with its build, and reachability is computed via BFS from
- # the root build's modules. The file is produced by gather-deps.sh.
- INCLUDED_BUILDS_ARG=""
- if [ -s build/dependabot-tools/included-builds.txt ]; then
- INCLUDED_BUILDS_ARG="--included-builds build/dependabot-tools/included-builds.txt"
- fi
- python3 .dismiss-bot/scripts/parse-gradle-deps.py \
- --input build/dependabot-tools/gather-deps.txt \
- --output build/dependabot-tools/dep-config-map.json \
- $INCLUDED_BUILDS_ARG
-
- - name: Build trusted-source currency map
- run: |
- python3 .dismiss-bot/scripts/build-trusted-currency.py \
- --dep-map build/dependabot-tools/dep-config-map.json \
- --alerts /tmp/dismiss-bot/alerts.json \
- --config build/dependabot-tools/dismiss-config.json \
- --output build/dependabot-tools/trusted-currency.json
-
- - name: Categorize and dismiss alerts
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- DRY_RUN: ${{ inputs.dry-run }}
- REPO: ${{ github.repository }}
- run: |
- set -euo pipefail
- DEP_MAP=build/dependabot-tools/dep-config-map.json
- CONFIG=build/dependabot-tools/dismiss-config.json
- CURRENCY=build/dependabot-tools/trusted-currency.json
-
- # Pretty values, each entry wrapped in backticks for the table.
- TRUSTED_SOURCES_TICKED=$(jq -r '.trusted_sources | map("`" + . + "`") | join(", ")' "$CONFIG")
- SCOPES_TICKED=$(jq -r '.trusted_source_scopes | map("`" + . + "`") | join(", ")' "$CONFIG")
- PROD_BT_TICKED=$(jq -r '.production_build_types | map("`" + . + "`") | join(", ")' "$CONFIG")
- PROD_V_TICKED=$(jq -r '.production_variants | map("`" + . + "`") | join(", ")' "$CONFIG")
- THRESHOLD=$(jq -r '.currency_threshold' "$CONFIG")
- PROD_V_DISPLAY=${PROD_V_TICKED:-_none_}
-
- {
- echo "## Configuration"
- echo ""
- echo "| Setting | Value | What it controls |"
- echo "| --- | --- | --- |"
- echo "| Trusted sources | ${TRUSTED_SOURCES_TICKED} | Maven groups whose transitives can be dismissed when the top-level is current. Anything else that pulls in a vulnerable dep is treated as unsafe. |"
- echo "| Trusted-source scopes | ${SCOPES_TICKED} | Where the trusted-source rule applies. Other scopes (\`production\`, \`codegen-main\`) stay closed unless explicitly added. |"
- echo "| Production build types | ${PROD_BT_TICKED} | Build types whose \`CompileClasspath\`/\`RuntimeClasspath\` configs count as production-shipping. |"
- echo "| Production variants (override) | ${PROD_V_DISPLAY} | When non-empty, replaces \`production-build-types\` with an exact variant list. |"
- echo "| Currency threshold | \`${THRESHOLD}\` | How close to the latest Maven release a trusted top-level must be. \`latest\` = exact match; \`same-minor\` = same \`major.minor\`. |"
- echo "| Dry run | \`${DRY_RUN}\` | When \`true\`, decisions are logged but no alerts are dismissed. |"
- echo ""
- echo "## Decisions"
- echo ""
- echo "An alert is **dismissed** only when every reachable occurrence is in a safe configuration:"
- echo ""
- echo "- **Test source sets** (\`test*\`, \`*AndroidTest*\`, \`*UnitTest*\`)"
- echo "- **Non-production variants** (build-type suffix not in \`production-build-types\`)"
- echo "- **Build tooling** (lint checks, Detekt, Jacoco, Dokka, etc.)"
- echo "- **Trusted-source transitives** in opted-in scopes, when the trusted top-level is current"
- echo ""
- echo "Otherwise the verdict is **skipped** — the alert stays open for human review. Occurrences in unreachable included-build modules are ignored. Click any alert number below to open it on GitHub."
- echo ""
- echo "| Alert | Package | Severity | Decision | Reason |"
- echo "| ----- | ------- | -------- | -------- | ------ |"
- } >> "$GITHUB_STEP_SUMMARY"
-
- while read -r ALERT; do
- NUM=$(printf "%s" "${ALERT}" | jq -r '.number')
- PKG=$(printf "%s" "${ALERT}" | jq -r '.dependency.package.name')
- SEV=$(printf "%s" "${ALERT}" | jq -r '.security_advisory.severity')
-
- RESULT=$(printf "%s" "${ALERT}" | python3 .dismiss-bot/scripts/categorize-alert.py \
- --dep-map "${DEP_MAP}" \
- --config "${CONFIG}" \
- --currency-map "${CURRENCY}")
-
- # categorize-alert.py emits plain text. The summary cell adds
- # markdown for visual hierarchy; the dismissal comment uses the
- # plain text verbatim (after truncation + run-URL append by the
- # helper).
- DECISION=$(printf "%s" "${RESULT}" | cut -f1)
- REASON=$(printf "%s" "${RESULT}" | cut -f2)
- DETAIL=$(printf "%s" "${RESULT}" | cut -f3-)
-
- case "${DECISION}" in
- dismiss) MARKER="✅ **Dismissed**" ;;
- skip) MARKER="❌ **Skipped**" ;;
- *) MARKER="${DECISION}" ;;
- esac
-
- ALERT_LINK="[#${NUM}](https://github.com/${REPO}/security/dependabot/${NUM})"
-
- # Markdown-only formatting for the summary cell: bold the leading
- # category clause (everything before the first " — "), and wrap
- # comma-separated items in the configs detail in inline-code.
- REASON_MD=$(printf "%s" "${REASON}" | sed -E 's/^([^—]+)( — )/**\1**\2/')
- if [ -n "${DETAIL}" ]; then
- # Wrap each item after "Configs: " in backticks.
- DETAIL_MD=$(printf "%s" "${DETAIL}" | sed -E 's/Configs: /Configs: `/; s/, \+/`, +/; s/, ([^,+]+)/`, `\1/g; s/([^`])$/\1`/')
- REASON_CELL="${REASON_MD}
${DETAIL_MD}"
- else
- REASON_CELL="${REASON_MD}"
- fi
-
- printf "| %s | %s | %s | %s | %s |\n" \
- "${ALERT_LINK}" "${PKG}" "${SEV}" "${MARKER}" "${REASON_CELL}" >> "$GITHUB_STEP_SUMMARY"
-
- if [ "${DECISION}" = "dismiss" ] && [ "${DRY_RUN}" != "true" ]; then
- # The Dependabot Alerts API caps `dismissed_comment` at 280
- # unicode characters. The helper truncates the body if needed
- # and appends a link to this run for the audit trail.
- RUN_URL="https://github.com/${REPO}/actions/runs/${GITHUB_RUN_ID}"
- DISMISS_COMMENT=$(python3 .dismiss-bot/scripts/format-dismissal-comment.py \
- "${REASON}" "${DETAIL}" "${RUN_URL}")
- gh api -X PATCH "repos/${REPO}/dependabot/alerts/${NUM}" \
- -f state=dismissed \
- -f dismissed_reason=not_used \
- -f dismissed_comment="${DISMISS_COMMENT}" >/dev/null
- fi
- done < <(jq -c '.[]' /tmp/dismiss-bot/alerts.json)
+ trusted-sources: ${{ inputs.trusted-sources }}
+ trusted-source-scopes: ${{ inputs.trusted-source-scopes }}
+ production-build-types: ${{ inputs.production-build-types }}
+ production-variants: ${{ inputs.production-variants }}
+ currency-threshold: ${{ inputs.currency-threshold }}
diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml
index 4c5e4e4..5904ce3 100644
--- a/.github/workflows/test-scripts.yml
+++ b/.github/workflows/test-scripts.yml
@@ -2,13 +2,7 @@ name: Test dismissal scripts
on:
push:
- paths:
- - "scripts/**"
- - ".github/workflows/test-scripts.yml"
pull_request:
- paths:
- - "scripts/**"
- - ".github/workflows/test-scripts.yml"
jobs:
test:
diff --git a/README.md b/README.md
index 98735c3..c2b1991 100644
--- a/README.md
+++ b/README.md
@@ -9,13 +9,33 @@ source sets, non-production variants, build tooling (lint, Detekt, Jacoco,
Dokka, …), or current transitives of trusted Maven groups. Alerts with any
production-reachable occurrence stay open for human review.
+### Architecture
+
+The analysis pipeline is packaged as a **composite action**
+([`.github/actions/dismiss-dev-only-dependabot-alerts`](.github/actions/dismiss-dev-only-dependabot-alerts/action.yml)) —
+the single source of truth. Two entry points wrap it:
+
+- **Reusable workflow** (the workflow linked above) — for the common case that needs no
+ custom setup. It gates on the open-alert count, checks out the caller repo,
+ then delegates to the composite action.
+- **Composite action, called directly** — for projects that must run their
+ own pre-steps between checkout and the analysis (install a CLI, fetch
+ secrets from Vault or any backend). See
+ [Calling the action directly](#calling-the-action-directly).
+
+See [When to use which](#when-to-use-which) to choose.
+
### How it works
1. **`list-alerts`** mints a GitHub App installation token (the default
`GITHUB_TOKEN` can't reach the Dependabot Alerts API) and fetches all open
alerts. If there are none, the `dismiss` job is skipped.
-2. **`dismiss`** checks out the caller repo and the tooling scripts, sets up
- JDK + Android SDK + Python, then runs the analysis pipeline:
+2. **`dismiss`** checks out the caller repo (submodules included — they are
+ other infinum private repos) and runs the composite action, which
+ materializes the Gradle wrapper jar from LFS, sets up JDK + Android SDK +
+ Python, then runs the analysis pipeline. The action re-fetches the open
+ alerts and self-gates on them, so it also skips the heavy work when nothing
+ is open. The pipeline scripts:
- [`gather-deps.sh`](scripts/gather-deps.sh) — per-module Gradle dependency
output (composite-build aware).
- [`parse-gradle-deps.py`](scripts/parse-gradle-deps.py) — turns that text
@@ -52,7 +72,9 @@ alerts are dismissed.
| --- | --- | --- |
| `app-private-key` | yes | PEM private key of the `infinum-dependabot-dismisser` GitHub App, used to mint a token with `Dependabot alerts: read/write` on the caller repo. |
-### Usage
+### Usage (reusable workflow)
+
+For the common case — a project that needs no setup beyond checkout:
```yaml
name: Dismiss dev-only Dependabot alerts
@@ -72,3 +94,77 @@ jobs:
app-private-key: ${{ secrets.DEPENDABOT_DISMISSER_BOT_PRIVATE_KEY }}
```
+### Calling the action directly
+
+Use the composite action directly when the project needs project-specific
+setup **after checkout but before Gradle configures** — for example fetching a
+private Maven repository URL from Vault. A reusable workflow cannot accept
+caller-provided `uses:` steps, so these projects own their checkout and setup
+and then hand off to the action.
+
+The action does **not** check out the repo or source any secrets; the caller
+job does both. Everything after checkout (including the LFS wrapper fix) runs
+inside the action against the already-checked-out workspace, so any setup you
+run before it — like the Vault fetch below — is in place when Gradle
+configures.
+
+Composite actions have no `secrets:` block, so the private key is passed as the
+`app-private-key` input. GitHub still masks it when it comes from a secret.
+
+```yaml
+name: Dismiss dev-only Dependabot alerts
+
+on:
+ schedule:
+ - cron: "0 6 * * 1"
+ workflow_dispatch:
+
+jobs:
+ dismiss:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/create-github-app-token@v3
+ id: token
+ with:
+ client-id: ${{ vars.DEPENDABOT_DISMISSER_BOT_APP_ID }}
+ private-key: ${{ secrets.DEPENDABOT_DISMISSER_BOT_PRIVATE_KEY }}
+ owner: ${{ github.repository_owner }}
+ - uses: actions/checkout@v6
+ with:
+ token: ${{ steps.token.outputs.token }} # needed for private submodules
+ submodules: recursive
+ # --- project-specific setup goes here ---
+ - uses: eLco/setup-vault@1a8cc6f3c818a0e71b6fade4a922eeec95069edf
+ - name: Fetch secrets from Vault
+ env:
+ VAULT_ADDR: ${{ vars.VAULT_ADDR }}
+ VAULT_AUTH_TOKEN: ${{ secrets.VAULT_AUTH_TOKEN }}
+ run: |
+ chmod +x ./vault-fetch.sh
+ ./vault-fetch.sh
+ # --- hand off to the shared engine ---
+ - uses: infinum/android-github-actions-workflows/.github/actions/dismiss-dev-only-dependabot-alerts@main
+ with:
+ client-id: ${{ vars.DEPENDABOT_DISMISSER_BOT_APP_ID }}
+ app-private-key: ${{ secrets.DEPENDABOT_DISMISSER_BOT_PRIVATE_KEY }}
+ dry-run: "false"
+```
+
+The action accepts the same tuning inputs as the reusable workflow
+([Inputs](#inputs) above), plus `app-private-key`.
+
+### When to use which
+
+| | Reusable workflow | Composite action (direct) |
+| --- | --- | --- |
+| Setup needed | None beyond checkout | Custom pre-steps (install tools, fetch secrets) |
+| Who checks out the repo | The workflow | Your job |
+| Secret handling | `secrets: app-private-key` | `with: app-private-key` (from a secret) |
+| Boilerplate | Minimal — one `uses:` | You write the checkout + setup steps |
+
+Prefer the reusable workflow. Reach for the action only when the workflow's
+fixed checkout-then-analyze sequence leaves no room for a step you must run in
+between.
+