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. +