[CHORE] [MER-0000] fix privsignal lockfile merging #93
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PrivSignal | |
| on: | |
| pull_request: | |
| branches: | |
| - master | |
| - hotfix-* | |
| - prerelease-* | |
| - nextgen-ux | |
| jobs: | |
| privsignal: | |
| name: PrivSignal (informational) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: 🛎️ Checkout PR branch | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| - name: 🧪 Setup Elixir | |
| uses: erlef/setup-elixir@v1 | |
| with: | |
| elixir-version: 1.19.2 | |
| otp-version: 28.1.1 | |
| - name: ⬇️ Install dependencies | |
| continue-on-error: true | |
| run: mix deps.get | |
| - name: 🔨 Compile PrivSignal dependency | |
| continue-on-error: true | |
| run: mix deps.compile priv_signal | |
| - name: ♻️ Regenerate lockfile (informational) | |
| id: lockfile | |
| shell: bash | |
| run: | | |
| set +e | |
| mkdir -p tmp/privsignal | |
| mix priv_signal.scan --quiet --json-path priv_signal.lockfile.json | |
| scan_exit=$? | |
| lockfile_changed=false | |
| if [ "$scan_exit" -eq 0 ]; then | |
| cp priv_signal.lockfile.json tmp/privsignal/ci.lockfile.json | |
| if ! git diff --quiet -- priv_signal.lockfile.json; then | |
| lockfile_changed=true | |
| fi | |
| fi | |
| echo "scan_exit=$scan_exit" >> "$GITHUB_OUTPUT" | |
| echo "lockfile_changed=$lockfile_changed" >> "$GITHUB_OUTPUT" | |
| # Informational only. | |
| exit 0 | |
| - name: 🔎 Run PrivSignal diff/score (informational) | |
| id: privsignal | |
| if: always() | |
| shell: bash | |
| run: | | |
| set +e | |
| mkdir -p tmp/privsignal | |
| DIFF_PATH="tmp/privsignal/ci.diff.json" | |
| SCORE_PATH="tmp/privsignal/ci.score.json" | |
| BASE_BRANCH_REF="origin/${{ github.base_ref }}" | |
| BASE_SHA_REF="${{ github.event.pull_request.base.sha }}" | |
| BASE_REF="" | |
| # Ensure latest target branch state is available locally. | |
| git fetch --no-tags origin "${{ github.base_ref }}" | |
| if [ -f "priv_signal.lockfile.json" ]; then | |
| if git cat-file -e "${BASE_BRANCH_REF}:priv_signal.lockfile.json" 2>/dev/null; then | |
| BASE_REF="$BASE_BRANCH_REF" | |
| elif git cat-file -e "${BASE_SHA_REF}:priv_signal.lockfile.json" 2>/dev/null; then | |
| BASE_REF="$BASE_SHA_REF" | |
| fi | |
| if [ -n "$BASE_REF" ]; then | |
| mix priv_signal.diff \ | |
| --base "$BASE_REF" \ | |
| --candidate-path priv_signal.lockfile.json \ | |
| --format json \ | |
| --output "$DIFF_PATH" | |
| diff_exit=$? | |
| else | |
| diff_exit=1 | |
| fi | |
| else | |
| diff_exit=1 | |
| fi | |
| if [ "$diff_exit" -eq 0 ]; then | |
| mix priv_signal.score --diff "$DIFF_PATH" --output "$SCORE_PATH" | |
| score_exit=$? | |
| else | |
| score_exit=1 | |
| fi | |
| if [ -f "$SCORE_PATH" ]; then | |
| score=$(jq -r '.score // "ERROR"' "$SCORE_PATH") | |
| else | |
| score="ERROR" | |
| fi | |
| echo "diff_exit=$diff_exit" >> "$GITHUB_OUTPUT" | |
| echo "score_exit=$score_exit" >> "$GITHUB_OUTPUT" | |
| echo "score=$score" >> "$GITHUB_OUTPUT" | |
| echo "base_ref=$BASE_REF" >> "$GITHUB_OUTPUT" | |
| # Informational only. | |
| exit 0 | |
| - name: 🧾 Build PrivSignal report | |
| if: always() | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| mkdir -p tmp/privsignal | |
| mix run --no-start .github/scripts/priv_signal_ci_report.exs -- \ | |
| tmp/privsignal/ci.score.json \ | |
| tmp/privsignal/ci.diff.json \ | |
| tmp/privsignal/ci.report.md \ | |
| tmp/privsignal/ci.report.json \ | |
| 25 | |
| - name: 🏷️ Sync privacy label | |
| if: always() | |
| id: privacy-label | |
| uses: actions/github-script@v7 | |
| env: | |
| PRIVSIGNAL_SCORE: ${{ steps.privsignal.outputs.score }} | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| if (!pr) { | |
| core.info("No pull_request context found; skipping privacy label sync."); | |
| core.setOutput("privacy_tier", "skipped"); | |
| core.setOutput("privacy_label", ""); | |
| return; | |
| } | |
| if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { | |
| core.warning("Fork PR detected; GitHub does not grant label write access to this workflow token. Skipping privacy label sync."); | |
| core.setOutput("privacy_tier", "fork-skip"); | |
| core.setOutput("privacy_label", ""); | |
| return; | |
| } | |
| const score = String(process.env.PRIVSIGNAL_SCORE || "").trim().toLowerCase(); | |
| const validTiers = new Set(["none", "low", "medium", "high"]); | |
| const managedLabels = [...validTiers].map((tier) => `privacy-${tier}`); | |
| if (!validTiers.has(score)) { | |
| core.warning(`PrivSignal score '${score || "missing"}' is not a recognized tier; leaving privacy labels unchanged.`); | |
| core.setOutput("privacy_tier", "unavailable"); | |
| core.setOutput("privacy_label", ""); | |
| return; | |
| } | |
| const desiredLabel = `privacy-${score}`; | |
| const { owner, repo } = context.repo; | |
| const issue_number = pr.number; | |
| const labelColors = { | |
| none: "2da44e", | |
| low: "1f6feb", | |
| medium: "d4a72c", | |
| high: "cf222e", | |
| }; | |
| try { | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: desiredLabel }); | |
| } catch (error) { | |
| if (error.status !== 404) { | |
| throw error; | |
| } | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: desiredLabel, | |
| color: labelColors[score], | |
| description: `PrivSignal privacy score: ${score}`, | |
| }); | |
| } | |
| const existing = await github.paginate(github.rest.issues.listLabelsOnIssue, { | |
| owner, | |
| repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| for (const label of existing) { | |
| if (managedLabels.includes(label.name) && label.name !== desiredLabel) { | |
| core.info(`Removing stale privacy label '${label.name}' from PR #${issue_number}`); | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number, | |
| name: label.name, | |
| }); | |
| } | |
| } | |
| if (!existing.some((label) => label.name === desiredLabel)) { | |
| core.info(`Adding privacy label '${desiredLabel}' to PR #${issue_number}`); | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number, | |
| labels: [desiredLabel], | |
| }); | |
| } | |
| core.setOutput("privacy_tier", score); | |
| core.setOutput("privacy_label", desiredLabel); | |
| } catch (error) { | |
| core.warning(`Unable to sync privacy label: ${error.message}`); | |
| core.setOutput("privacy_tier", "unavailable"); | |
| core.setOutput("privacy_label", ""); | |
| } | |
| - name: 📌 Publish job summary | |
| if: always() | |
| shell: bash | |
| run: | | |
| if [ -f tmp/privsignal/ci.report.md ]; then | |
| cat tmp/privsignal/ci.report.md >> "$GITHUB_STEP_SUMMARY" | |
| if [ -n "${{ steps.privacy-label.outputs.privacy_label }}" ]; then | |
| { | |
| echo | |
| echo "Applied PR label: \`${{ steps.privacy-label.outputs.privacy_label }}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| else | |
| { | |
| echo "## PrivSignal (Informational)" | |
| echo | |
| echo "Unable to generate report." | |
| echo | |
| echo "- scan exit: ${{ steps.lockfile.outputs.scan_exit }}" | |
| echo "- lockfile changed: ${{ steps.lockfile.outputs.lockfile_changed }}" | |
| echo "- base ref used: ${{ steps.privsignal.outputs.base_ref }}" | |
| echo "- diff exit: ${{ steps.privsignal.outputs.diff_exit }}" | |
| echo "- score exit: ${{ steps.privsignal.outputs.score_exit }}" | |
| echo "- privacy label: ${{ steps.privacy-label.outputs.privacy_label }}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: 📦 Upload PrivSignal artifacts | |
| if: always() | |
| id: upload-artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: privsignal-artifacts-${{ github.event.pull_request.number }} | |
| path: | | |
| priv_signal.lockfile.json | |
| tmp/privsignal/ci.lockfile.json | |
| tmp/privsignal/ci.diff.json | |
| tmp/privsignal/ci.score.json | |
| tmp/privsignal/ci.report.md | |
| tmp/privsignal/ci.report.json | |
| if-no-files-found: warn | |
| - name: 🔗 Publish artifact link | |
| if: always() && steps.upload-artifact.outputs.artifact-url != '' | |
| shell: bash | |
| run: | | |
| { | |
| echo | |
| echo "Artifact bundle: [privsignal-artifacts-${{ github.event.pull_request.number }}](${{ steps.upload-artifact.outputs.artifact-url }})" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: 💬 Upsert PR comment | |
| if: always() | |
| uses: actions/github-script@v7 | |
| env: | |
| PRIVSIGNAL_SCORE: ${{ steps.privsignal.outputs.score }} | |
| PRIVSIGNAL_BASE_REF: ${{ steps.privsignal.outputs.base_ref }} | |
| PRIVSIGNAL_DIFF_EXIT: ${{ steps.privsignal.outputs.diff_exit }} | |
| PRIVSIGNAL_SCORE_EXIT: ${{ steps.privsignal.outputs.score_exit }} | |
| PRIVSIGNAL_SCAN_EXIT: ${{ steps.lockfile.outputs.scan_exit }} | |
| PRIVSIGNAL_LOCKFILE_CHANGED: ${{ steps.lockfile.outputs.lockfile_changed }} | |
| PRIVSIGNAL_LABEL: ${{ steps.privacy-label.outputs.privacy_label }} | |
| PRIVSIGNAL_ARTIFACT_URL: ${{ steps.upload-artifact.outputs.artifact-url }} | |
| with: | |
| script: | | |
| const fs = require("node:fs"); | |
| const marker = "<!-- privsignal-report -->"; | |
| const pr = context.payload.pull_request; | |
| if (!pr) { | |
| core.info("No pull_request context found; skipping PrivSignal PR comment."); | |
| return; | |
| } | |
| if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { | |
| core.warning("Fork PR detected; GitHub does not grant comment write access to this workflow token. Skipping PrivSignal PR comment."); | |
| return; | |
| } | |
| const readJson = (path) => { | |
| try { | |
| return JSON.parse(fs.readFileSync(path, "utf8")); | |
| } catch (_error) { | |
| return null; | |
| } | |
| }; | |
| const report = readJson("tmp/privsignal/ci.report.json"); | |
| const esc = (value) => | |
| String(value ?? "n/a") | |
| .replace(/\|/g, "\\|") | |
| .replace(/\n/g, " "); | |
| const renderTable = (rows) => { | |
| if (!rows.length) { | |
| return "_None_"; | |
| } | |
| const header = [ | |
| "| Rule | Type | Class | Source | Sink | Location |", | |
| "|---|---|---|---|---|---|", | |
| ]; | |
| const body = rows.map((row) => | |
| `| ${esc(row.rule_id)} | ${esc(row.event_type)} | ${esc(row.event_class)} | ${esc(row.source)} | ${esc(row.sink)} | ${esc(row.location)} |` | |
| ); | |
| return [...header, ...body].join("\n"); | |
| }; | |
| const score = process.env.PRIVSIGNAL_SCORE || "ERROR"; | |
| const summary = report?.summary || {}; | |
| const reasons = Array.isArray(report?.reason_details) ? report.reason_details : []; | |
| const samples = Array.isArray(report?.events_sample) ? report.events_sample : []; | |
| const artifactUrl = process.env.PRIVSIGNAL_ARTIFACT_URL || ""; | |
| const privacyLabel = process.env.PRIVSIGNAL_LABEL || ""; | |
| const links = artifactUrl | |
| ? [`[artifact bundle](${artifactUrl})`] | |
| : []; | |
| const lines = [ | |
| marker, | |
| "## PrivSignal Report", | |
| "", | |
| `Top-level result: \`${esc(score)}\`${privacyLabel ? ` with PR label \`${esc(privacyLabel)}\`` : ""}`, | |
| "", | |
| "| Score | High | Medium | Low | Total |", | |
| "|---|---:|---:|---:|---:|", | |
| `| \`${esc(score)}\` | ${esc(summary.events_high || 0)} | ${esc(summary.events_medium || 0)} | ${esc(summary.events_low || 0)} | ${esc(summary.events_total || 0)} |`, | |
| "", | |
| ]; | |
| if (links.length) { | |
| lines.push(`Links: ${links.join(" | ")}`); | |
| lines.push(""); | |
| } | |
| lines.push("| Detail | Value |"); | |
| lines.push("|---|---|"); | |
| lines.push(`| Base ref used | \`${esc(process.env.PRIVSIGNAL_BASE_REF || "n/a")}\` |`); | |
| lines.push(`| Scan exit | \`${esc(process.env.PRIVSIGNAL_SCAN_EXIT || "n/a")}\` |`); | |
| lines.push(`| Diff exit | \`${esc(process.env.PRIVSIGNAL_DIFF_EXIT || "n/a")}\` |`); | |
| lines.push(`| Score exit | \`${esc(process.env.PRIVSIGNAL_SCORE_EXIT || "n/a")}\` |`); | |
| lines.push(`| Lockfile changed | \`${esc(process.env.PRIVSIGNAL_LOCKFILE_CHANGED || "n/a")}\` |`); | |
| lines.push(`| Reason events | \`${reasons.length}\` |`); | |
| lines.push(`| Sample events shown | \`${samples.length}\` |`); | |
| lines.push(""); | |
| lines.push("### Reason Events"); | |
| lines.push(""); | |
| lines.push(renderTable(reasons)); | |
| lines.push(""); | |
| lines.push("### Sample Of All Events"); | |
| lines.push(""); | |
| lines.push(renderTable(samples)); | |
| if (!report) { | |
| lines.splice( | |
| 2, | |
| lines.length - 2, | |
| "", | |
| "Unable to load `tmp/privsignal/ci.report.json` for this run.", | |
| "", | |
| "| Detail | Value |", | |
| "|---|---|", | |
| `| Score output | \`${esc(score)}\` |`, | |
| `| Base ref used | \`${esc(process.env.PRIVSIGNAL_BASE_REF || "n/a")}\` |`, | |
| `| Scan exit | \`${esc(process.env.PRIVSIGNAL_SCAN_EXIT || "n/a")}\` |`, | |
| `| Diff exit | \`${esc(process.env.PRIVSIGNAL_DIFF_EXIT || "n/a")}\` |`, | |
| `| Score exit | \`${esc(process.env.PRIVSIGNAL_SCORE_EXIT || "n/a")}\` |`, | |
| `| Lockfile changed | \`${esc(process.env.PRIVSIGNAL_LOCKFILE_CHANGED || "n/a")}\` |` | |
| ); | |
| if (links.length) { | |
| lines.push(""); | |
| lines.push(`Links: ${links.join(" | ")}`); | |
| } | |
| } | |
| const body = lines.join("\n"); | |
| const { owner, repo } = context.repo; | |
| const issue_number = pr.number; | |
| try { | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find((comment) => comment.body && comment.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body, | |
| }); | |
| } | |
| } catch (error) { | |
| core.warning(`Unable to upsert PrivSignal PR comment: ${error.message}`); | |
| } |