diff --git a/.github/workflows/gptchangelog.yml b/.github/workflows/gptchangelog.yml index 1c2ebe1..01a2b9e 100644 --- a/.github/workflows/gptchangelog.yml +++ b/.github/workflows/gptchangelog.yml @@ -255,7 +255,7 @@ jobs: uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 id: app-token with: - app-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} + client-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} - name: Checkout repository @@ -732,8 +732,10 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Send Slack notification for sync PR - uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0 + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook payload: | { "blocks": [ @@ -770,6 +772,3 @@ jobs: } ] } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index 561b7df..0b9e76a 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -22,7 +22,7 @@ on: type: string required: true base_branch: - description: 'Target branch for the PR (default: develop)' + description: 'Target branch for the PR. Allowed: main, develop, master, staging (default: main)' type: string default: 'main' scripts_path: @@ -93,16 +93,33 @@ on: description: 'Slack bot user ID for Severino (@severino)' required: false +permissions: + contents: read + jobs: update-chart: name: Update Chart runs-on: ${{ inputs.runner_type }} steps: + - name: Validate base_branch + env: + BASE_BRANCH: ${{ inputs.base_branch }} + run: | + case "${BASE_BRANCH}" in + main|develop|master|staging) + echo "✅ base_branch '${BASE_BRANCH}' is allowed" + ;; + *) + echo "::error::Invalid base_branch '${BASE_BRANCH}'. Allowed: main, develop, master, staging" + exit 1 + ;; + esac + - name: Generate GitHub App Token id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Extract payload @@ -149,16 +166,19 @@ jobs: # Save components array to file for processing jq -c '.components' /tmp/payload.json > /tmp/components.json - # CodeQL: untrusted-checkout — false positive. This is a workflow_call - # triggered by internal dispatch, not a PR event. The ref is a controlled - # branch name (develop/main), not an untrusted PR head. - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: token: ${{ steps.app-token.outputs.token }} - ref: ${{ inputs.base_branch }} fetch-depth: 0 + - name: Switch to base branch + env: + BASE_BRANCH: ${{ inputs.base_branch }} + run: | + git fetch --no-tags origin "${BASE_BRANCH}" + git checkout -B "${BASE_BRANCH}" "origin/${BASE_BRANCH}" + - name: Import GPG key if: ${{ inputs.gpg_sign_commits }} uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 diff --git a/.github/workflows/release-notification.yml b/.github/workflows/release-notification.yml index a36cd34..a8e74bc 100644 --- a/.github/workflows/release-notification.yml +++ b/.github/workflows/release-notification.yml @@ -105,6 +105,9 @@ on: type: boolean default: false +permissions: + contents: read + jobs: notify: name: Release Notification @@ -117,7 +120,7 @@ jobs: uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 id: app-token with: - app-id: ${{ secrets.APP_ID }} + client-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2de026..6aca0ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,9 @@ on: type: string default: '2' +permissions: + contents: read + jobs: prepare: runs-on: ${{ inputs.runner_type }} @@ -106,7 +109,7 @@ jobs: - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 id: app-token with: - app-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} + client-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} - name: Checkout repository diff --git a/.github/workflows/self-release.yml b/.github/workflows/self-release.yml index 2e6be73..0e32d93 100644 --- a/.github/workflows/self-release.yml +++ b/.github/workflows/self-release.yml @@ -43,3 +43,39 @@ jobs: runner_type: ubuntu-latest stable_releases_only: true secrets: inherit + + # Force-update the floating major version tag (e.g. v1) to point at the + # latest stable release. Enables downstream composites to pin to @v1 and + # always resolve to the latest stable v1.x.x release. Stable-only: gated to + # main so beta/rc releases from develop/release-candidate don't move v1. + update-major-tag: + needs: publish-release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + id: app-token + with: + client-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} + private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 + with: + gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} + passphrase: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY_PASSWORD }} + git_committer_name: ${{ secrets.LERIAN_CI_CD_USER_NAME }} + git_committer_email: ${{ secrets.LERIAN_CI_CD_USER_EMAIL }} + git_config_global: true + git_user_signingkey: true + git_tag_gpgsign: true + + - name: Update floating major version tag + uses: ./src/config/update-major-tag diff --git a/.github/workflows/typescript-release.yml b/.github/workflows/typescript-release.yml index 511efab..0617952 100644 --- a/.github/workflows/typescript-release.yml +++ b/.github/workflows/typescript-release.yml @@ -117,7 +117,7 @@ jobs: - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 id: app-token with: - app-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} + client-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} - name: Checkout repository diff --git a/src/config/update-major-tag/README.md b/src/config/update-major-tag/README.md new file mode 100644 index 0000000..b66b548 --- /dev/null +++ b/src/config/update-major-tag/README.md @@ -0,0 +1,75 @@ + + + + + +
Lerian

update-major-tag

+ +Force-update the floating major version tag (e.g. `v1`) to point at the latest stable `vX.Y.Z` tag in the repository. Intended to run after a successful stable release so callers can pin composite actions to `@v1` and always resolve to the latest stable release. + +### Behavior + +1. Fetches all tags from the remote. +2. Finds the greatest stable tag matching `^v[0-9]+\.[0-9]+\.[0-9]+$` (pre-release tags like `-beta.N` / `-rc.N` are ignored). +3. Derives the major prefix (`v1.26.0 → v1`). +4. If the major tag already points at the resolved commit, exits with a notice — idempotent. +5. Otherwise, creates/moves the major tag as an annotated tag and force-pushes it. + +### Assumptions + +- The caller has already checked out the repository with `fetch-depth: 0` (so all tags are reachable). +- The checkout was authenticated with a token that has permission to push tags (typically via `actions/checkout@... with.token:`). +- For signed tags, the caller has imported a GPG key and enabled `git_tag_gpgsign` (`git tag -a` will auto-sign when `tag.gpgSign=true` is set globally). + +## Inputs + +_None._ All behavior is derived from the repository's tag list. + +## Outputs + +| Output | Description | +|---|---| +| `skip` | `true` when no tag update was performed — either no stable tag was found, or the major tag already pointed at the latest stable commit | +| `tag-updated` | `true` when the floating major tag was force-pushed to a new commit | +| `major-tag` | The major tag name that was considered (e.g. `v1`). Empty when no stable tag was found | +| `latest-tag` | The latest stable tag the major tag was aligned with (e.g. `v1.26.0`). Empty when no stable tag was found | + +## Usage + +```yaml +jobs: + update-major-tag: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/create-github-app-token@ # v3.1.1 + id: app-token + with: + client-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@ # v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - uses: crazy-max/ghaction-import-gpg@ # v7 + with: + gpg_private_key: ${{ secrets.GPG_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_committer_name: ${{ secrets.CI_USER_NAME }} + git_committer_email: ${{ secrets.CI_USER_EMAIL }} + git_config_global: true + git_user_signingkey: true + git_tag_gpgsign: true + + - uses: LerianStudio/github-actions-shared-workflows/src/config/update-major-tag@v1 +``` + +## Required permissions + +```yaml +permissions: + contents: write +``` diff --git a/src/config/update-major-tag/action.yml b/src/config/update-major-tag/action.yml new file mode 100644 index 0000000..48a0527 --- /dev/null +++ b/src/config/update-major-tag/action.yml @@ -0,0 +1,83 @@ +name: Update Major Version Tag +description: Force-update the floating major version tag (e.g. v1) to point at the latest stable semver tag (vX.Y.Z) found in the repository. + +outputs: + skip: + description: 'true when no tag update was performed (no stable tag found, or major already points at the latest stable commit)' + value: ${{ steps.update.outputs.skip }} + tag-updated: + description: 'true when the floating major tag was force-pushed to a new commit' + value: ${{ steps.update.outputs.tag-updated }} + major-tag: + description: 'The major tag name that was considered (e.g. v1). Empty when no stable tag was found.' + value: ${{ steps.update.outputs.major-tag }} + latest-tag: + description: 'The latest stable tag the major tag was aligned with (e.g. v1.26.0). Empty when no stable tag was found.' + value: ${{ steps.update.outputs.latest-tag }} + +runs: + using: composite + steps: + - id: update + name: Resolve and push major version tag + shell: bash + run: | + set -euo pipefail + + git fetch --tags --force --prune + + LATEST=$(git tag --list \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V \ + | tail -n1 || true) + + if [ -z "$LATEST" ]; then + echo "::notice::No stable release tag found — skipping major tag update." + { + echo "skip=true" + echo "tag-updated=false" + echo "major-tag=" + echo "latest-tag=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + VERSION="${LATEST#v}" + MAJOR="v${VERSION%%.*}" + SHA=$(git rev-list -n1 "refs/tags/$LATEST") + + CURRENT_SHA="" + # Ref snapshot taken right after the initial fetch: using this as the + # lease ensures the push aborts if another run advances $MAJOR between + # our fetch and our push (re-reading the remote later would defeat the + # lease, since it would reflect the other run's update). + CURRENT_MAJOR_REF="" + if git rev-parse --verify --quiet "refs/tags/$MAJOR" >/dev/null; then + CURRENT_SHA=$(git rev-list -n1 "refs/tags/$MAJOR") + CURRENT_MAJOR_REF=$(git rev-parse "refs/tags/$MAJOR") + fi + + if [ "$CURRENT_SHA" = "$SHA" ]; then + echo "::notice::$MAJOR already points at $LATEST ($SHA) — nothing to do." + { + echo "skip=true" + echo "tag-updated=false" + echo "major-tag=$MAJOR" + echo "latest-tag=$LATEST" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Moving $MAJOR → $LATEST ($SHA)" + git tag -f -a "$MAJOR" "$SHA" -m "Release $MAJOR ($LATEST)" + + LEASE_SHA="${CURRENT_MAJOR_REF:-0000000000000000000000000000000000000000}" + git push origin "refs/tags/$MAJOR:refs/tags/$MAJOR" \ + --force-with-lease="refs/tags/$MAJOR:$LEASE_SHA" + + { + echo "skip=false" + echo "tag-updated=true" + echo "major-tag=$MAJOR" + echo "latest-tag=$LATEST" + } >> "$GITHUB_OUTPUT" diff --git a/src/lint/pinned-actions/README.md b/src/lint/pinned-actions/README.md index 02578de..41bba25 100644 --- a/src/lint/pinned-actions/README.md +++ b/src/lint/pinned-actions/README.md @@ -5,35 +5,62 @@ -Ensure all third-party GitHub Action references use pinned versions (`@vX.Y.Z` or `@sha`), not mutable refs like `@main` or `@master`. +Enforces the repository pinning policy on every `uses:` reference in workflow and composite files. External actions must be pinned by commit SHA; internal (`LerianStudio/`) references are validated against the composite-vs-reusable policy below. + +## Policy + +| Ref kind | Detection | Required pin | Severity on violation | +|---|---|---|---| +| **External action** (anything outside the org) | `uses:` path does not match any `warn-patterns` prefix | Full commit SHA (40–64 hex chars) | ❌ **error** — fails the step | +| **Internal composite** | `uses:` path contains `/src/` | Floating major tag (`@v1`, `@v2`, …) or testing branch (`@develop` / `@main`) | ⚠️ **warning** | +| **Internal reusable workflow** | `uses:` path contains `/.github/workflows/` | Exact version tag (`@v1.2.3`, `@v1.2.3-beta.1`) or testing branch (`@develop` / `@main`) | ⚠️ **warning** | +| **Internal, unknown shape** | matches `warn-patterns` but neither path pattern above | Any semver-like tag or testing branch (legacy tolerance) | ⚠️ **warning** | + +**Rationale** + +- Composites are small, low-risk building blocks. Floating `@v1` lets callers track the latest stable major without bumping per patch — the repo release pipeline moves `@v1` atomically on each stable release in `main`. +- Reusable workflows orchestrate entire pipelines with broader blast radius. Exact pinning (`@v1.2.3`) forces explicit, auditable caller updates. +- External actions use SHA pins because upstream tags are mutable — Dependabot keeps the SHAs fresh automatically. ## Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| -| `files` | Space-separated list of workflow/composite files to check | No | `` | -| `ignore-patterns` | Pipe-separated org/owner prefixes to skip (e.g. internal actions) | No | `LerianStudio/` | +| `files` | Comma-separated list of workflow/composite files to check (empty = skip) | No | `""` | +| `warn-patterns` | Pipe-separated org/owner prefixes treated as internal (warn-only) | No | `LerianStudio/` | -## Usage as composite step +## Usage ```yaml -- name: Checkout - uses: actions/checkout@v4 - - name: Pinned Actions Check - uses: LerianStudio/github-actions-shared-workflows/src/lint/pinned-actions@v1.2.3 + uses: LerianStudio/github-actions-shared-workflows/src/lint/pinned-actions@v1 with: - files: ".github/workflows/ci.yml .github/workflows/deploy.yml" - ignore-patterns: "LerianStudio/|my-org/" + files: ".github/workflows/ci.yml,.github/workflows/deploy.yml" ``` -## Usage via reusable workflow +## Examples ```yaml -jobs: - lint: - uses: LerianStudio/github-actions-shared-workflows/.github/workflows/pr-lint.yml@v1.2.3 - secrets: inherit +# ✅ External action — SHA pin (required) +uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + +# ❌ External action — tag pin (error) +uses: actions/checkout@v6 + +# ✅ Internal composite — floating major +uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-fs-scan@v1 + +# ⚠️ Internal composite — exact version (warning, should be @v1) +uses: LerianStudio/github-actions-shared-workflows/src/security/trivy-fs-scan@v1.24.1 + +# ✅ Internal reusable workflow — exact version +uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release.yml@v1.26.0 + +# ⚠️ Internal reusable workflow — floating major (warning, should be @vX.Y.Z) +uses: LerianStudio/github-actions-shared-workflows/.github/workflows/release.yml@v1 + +# ✅ Either kind — develop/main for testing a change +uses: LerianStudio/github-actions-shared-workflows/src/config/labels-sync@develop ``` ## Required permissions diff --git a/src/lint/pinned-actions/action.yml b/src/lint/pinned-actions/action.yml index b95030b..b3d8bdc 100644 --- a/src/lint/pinned-actions/action.yml +++ b/src/lint/pinned-actions/action.yml @@ -57,12 +57,32 @@ runs: done if [ "$is_internal" = true ]; then - # Internal: any semver ref passes — vX, vX.Y.Z, vX.Y.Z-beta.N, vX.Y.Z-rc.N, branches like develop/main - if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+(\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)?$|^(develop|main)$'; then - continue + # Internal pinning policy: + # - Composite actions (path contains /src/) → floating major tag @vN + # - Reusable workflows (path contains /.github/workflows/) → exact version tag @vN.M.P[-pre] + # - Testing branches (develop|main) are always accepted for both + # - Unknown internal ref shapes fall back to any semver ref (legacy tolerance) + target=${normalized%@*} + + if printf '%s\n' "$target" | grep -Fq '/src/'; then + if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+$|^(develop|main)$'; then + continue + fi + echo "::warning file=${file},line=${line_num}::Internal composite must use floating major tag (e.g. @v1) or develop/main for testing: $normalized" + warnings=$((warnings + 1)) + elif printf '%s\n' "$target" | grep -Fq '/.github/workflows/'; then + if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$|^(develop|main)$'; then + continue + fi + echo "::warning file=${file},line=${line_num}::Internal reusable workflow must use exact version tag (e.g. @v1.2.3) or develop/main for testing: $normalized" + warnings=$((warnings + 1)) + else + if printf '%s\n' "$ref" | grep -Eq '^v[0-9]+(\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)?$|^(develop|main)$'; then + continue + fi + echo "::warning file=${file},line=${line_num}::Internal action not pinned to a version: $normalized" + warnings=$((warnings + 1)) fi - echo "::warning file=${file},line=${line_num}::Internal action not pinned to a version: $normalized" - warnings=$((warnings + 1)) else # External: must be a commit SHA (40 or 64 hex chars) if printf '%s\n' "$ref" | grep -Eiq '^[0-9a-f]{40,64}$'; then diff --git a/src/security/codeql-reporter/README.md b/src/security/codeql-reporter/README.md index c042875..9eb3a98 100644 --- a/src/security/codeql-reporter/README.md +++ b/src/security/codeql-reporter/README.md @@ -7,6 +7,8 @@ Composite action that reads CodeQL SARIF output and posts a formatted security report as a PR comment. Uses an idempotent comment strategy (updates existing comment on re-runs). Designed to run after [`codeql-analyze`](../codeql-analyze/). +Findings parsed from SARIF are cross-referenced with the Code Scanning REST API (`listAlertsForRepo` on `refs/pull//merge`) so alerts that were **dismissed** via the Security tab or are already **fixed** on the merge ref do not show up in the PR comment. A footer notes the number of hidden findings so reviewers know the comment omitted some. If the API call fails (e.g. missing permissions), the action degrades gracefully to the SARIF-only view and emits a warning. + ## Inputs | Input | Description | Required | Default | @@ -54,7 +56,9 @@ steps: ```yaml permissions: contents: read - security-events: write + security-events: write # required for alert-state enrichment; `read` is enough on its own pull-requests: write actions: read ``` + +> `security-events: write` (already required by [`codeql-analyze`](../codeql-analyze/)) covers the `listAlertsForRepo` call used to filter dismissed/fixed alerts. If only `read` is granted the enrichment still works; if the permission is missing entirely the action falls back to the raw SARIF view with a warning. diff --git a/src/security/codeql-reporter/action.yml b/src/security/codeql-reporter/action.yml index 9c76614..da87a60 100644 --- a/src/security/codeql-reporter/action.yml +++ b/src/security/codeql-reporter/action.yml @@ -73,6 +73,18 @@ runs: const truncate = (str, max) => str.length > max ? str.substring(0, max - 3) + '...' : str; + // Normalize file paths to repo-relative form. SARIF can emit absolute + // file:// URIs (e.g. file:///github/workspace/src/foo.js) or leading + // ./ prefixes, while the Code Scanning API always returns clean + // repo-relative paths — normalizing both sides keeps the (rule, path, + // line) lookup key aligned. + const normalizePath = (p) => + String(p ?? '') + .replace(/^file:\/\/\/github\/workspace\//, '') + .replace(/^file:\/\//, '') + .replace(/^\.\//, '') + .replace(/^\//, ''); + // ── Read SARIF files ── function readSarifFiles() { const findings = []; @@ -109,7 +121,7 @@ runs: rule: result.ruleId || 'unknown', severity: result.level || 'warning', message: result.message?.text || rule.shortDescription?.text || 'No description', - file: location?.artifactLocation?.uri || 'unknown', + file: normalizePath(location?.artifactLocation?.uri) || 'unknown', line: location?.region?.startLine || 0, description: rule.fullDescription?.text || rule.shortDescription?.text || '', tags: rule.properties?.tags || [], @@ -129,8 +141,72 @@ runs: // unpinned-tag is handled by our own pinned-actions lint check with org-aware logic const SUPPRESSED_RULES = ['actions/unpinned-tag']; + // ── Enrich findings with Code Scanning alert states ── + // SARIF is structural: it re-reports the same locations on every run, even after + // a reviewer dismisses the alert via the Security tab. Cross-reference the REST + // API so dismissed/fixed alerts stop showing up in the PR comment. + async function filterByAlertStates(rawFindings) { + const prNumber = context.payload.pull_request?.number || context.issue?.number; + if (!prNumber) { + return { findings: rawFindings, hidden: 0, enriched: false }; + } + + let alerts; + try { + alerts = await github.paginate( + github.rest.codeScanning.listAlertsForRepo, + { + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/pull/${prNumber}/merge`, + per_page: 100, + } + ); + } catch (e) { + core.warning( + `Could not fetch Code Scanning alert states (falling back to SARIF-only view): ${e.message}` + ); + return { findings: rawFindings, hidden: 0, enriched: false }; + } + + // Bucket alert states by (ruleId, path, startLine). A single location can carry + // multiple alert rows across history; if any are currently open, we must not + // suppress it — only suppress when every alert at that location is dismissed/fixed. + const statesByKey = new Map(); + for (const alert of alerts) { + const ruleId = alert.rule?.id; + const loc = alert.most_recent_instance?.location; + const alertPath = normalizePath(loc?.path); + if (!ruleId || !alertPath) continue; + const key = `${ruleId}\u0000${alertPath}\u0000${loc?.start_line ?? 0}`; + if (!statesByKey.has(key)) statesByKey.set(key, []); + statesByKey.get(key).push(alert.state); + } + + const suppressedKeys = new Set(); + for (const [key, states] of statesByKey) { + if (states.includes('open')) continue; + if (states.some(s => s === 'dismissed' || s === 'fixed')) { + suppressedKeys.add(key); + } + } + + const kept = []; + let hidden = 0; + for (const f of rawFindings) { + const key = `${f.rule}\u0000${f.file}\u0000${f.line || 0}`; + if (suppressedKeys.has(key)) { + hidden++; + } else { + kept.push(f); + } + } + return { findings: kept, hidden, enriched: true }; + } + // ── Build Report ── - const findings = readSarifFiles().filter(f => !SUPPRESSED_RULES.includes(f.rule)); + const rawFindings = readSarifFiles().filter(f => !SUPPRESSED_RULES.includes(f.rule)); + const { findings, hidden: hiddenCount, enriched } = await filterByAlertStates(rawFindings); findingsCount = findings.length; body += `## \u{1F6E1}\uFE0F CodeQL Analysis Results\n\n`; @@ -142,6 +218,9 @@ runs: body += `\u26A0\uFE0F Some SARIF files could not be parsed and no findings were recovered. Check the analysis step logs.\n\n`; } else if (findings.length === 0) { body += `\u2705 No security issues found.\n\n`; + if (enriched && hiddenCount > 0) { + body += `_${hiddenCount} finding(s) hidden (dismissed or fixed). See the Security tab for the full list._\n\n`; + } } else { hasFindings = true; @@ -179,6 +258,10 @@ runs: } body += '\n'; + + if (enriched && hiddenCount > 0) { + body += `_${hiddenCount} finding(s) hidden (dismissed or fixed). See the Security tab for the full list._\n\n`; + } } // ── Useful Links ──