143 lines
5.4 KiB
YAML
143 lines
5.4 KiB
YAML
name: Daily Product Intelligence
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
schedule:
|
|
# 06:00 UTC daily
|
|
- cron: "0 6 * * *"
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
issues: write
|
|
|
|
concurrency:
|
|
group: product-intelligence-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
report:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 24
|
|
|
|
- name: Collect aggregate product signals
|
|
id: collect
|
|
continue-on-error: true
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
# SENTRY_PRODUCT_INTELLIGENCE_TOKEN is a dedicated read-only token and is
|
|
# preferred if/when it exists; falls back to the general-purpose
|
|
# SENTRY_AUTH_TOKEN so the daily cron doesn't silently no-op (#60).
|
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN || secrets.SENTRY_AUTH_TOKEN }}
|
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
|
run: |
|
|
node scripts/product-intelligence.mjs \
|
|
--report "$RUNNER_TEMP/product-intelligence.md" \
|
|
--json "$RUNNER_TEMP/product-intelligence.json"
|
|
|
|
- name: Create material-signal issue
|
|
if: always()
|
|
uses: actions/github-script@v8
|
|
env:
|
|
REPORT_PATH: ${{ runner.temp }}/product-intelligence.json
|
|
with:
|
|
script: |
|
|
const fs = require("fs")
|
|
|
|
if (!fs.existsSync(process.env.REPORT_PATH)) {
|
|
core.setFailed("Product intelligence collector produced no report.")
|
|
return
|
|
}
|
|
|
|
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, "utf8"))
|
|
const allowedSignals = new Set([
|
|
"new-sentry-issue",
|
|
"repeated-workflow-failure",
|
|
])
|
|
const validDate = typeof report.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(report.date)
|
|
const validSignals = Array.isArray(report.signals) &&
|
|
report.signals.length > 0 &&
|
|
report.signals.every((signal) => allowedSignals.has(signal))
|
|
|
|
if (!report.material) {
|
|
core.info("No material signal. No GitHub issue created.")
|
|
return
|
|
}
|
|
if (!validDate || !validSignals) {
|
|
core.setFailed("Collector report failed public issue allowlist validation.")
|
|
return
|
|
}
|
|
|
|
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
state: "open",
|
|
per_page: 100,
|
|
})
|
|
const title = "Product intelligence: material signal"
|
|
const marker = `<!-- product-intelligence:${report.date}:${[...report.signals].sort().join(",")} -->`
|
|
const body = [
|
|
marker,
|
|
"",
|
|
"## Material aggregate signal",
|
|
"",
|
|
`The daily product-intelligence run detected: ${[...report.signals].sort().join(", ")}.`,
|
|
"",
|
|
"This issue intentionally contains no raw diagnostic, review, request, or user-generated content. Review the sanitized Actions artifact and reproduce the behavior in the affected user channel before implementing a fix.",
|
|
].join("\n")
|
|
const existing = issues.find((issue) => issue.title === title)
|
|
|
|
if (existing) {
|
|
await github.rest.issues.update({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: existing.number,
|
|
title,
|
|
body,
|
|
})
|
|
core.info(`Updated #${existing.number}.`)
|
|
return
|
|
}
|
|
|
|
const { data: issue } = await github.rest.issues.create({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
title,
|
|
body,
|
|
labels: ["P1"],
|
|
})
|
|
core.info(`Created #${issue.number}.`)
|
|
|
|
- name: Upload sanitized report
|
|
if: always()
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: product-intelligence-${{ github.run_id }}
|
|
path: |
|
|
${{ runner.temp }}/product-intelligence.md
|
|
${{ runner.temp }}/product-intelligence.json
|
|
if-no-files-found: error
|
|
|
|
- name: Fail when a required source is unavailable
|
|
# scripts/product-intelligence.mjs only sets a non-zero exit code for
|
|
# genuinely required-source failures (GitHub unavailable, or Sentry
|
|
# unavailable for a reason other than the known missing/rejected
|
|
# SENTRY_PRODUCT_INTELLIGENCE_TOKEN gap). A Sentry-token-missing run
|
|
# is reported as "degraded" and this step does not fire for it — see
|
|
# isSentryProvisioningGap() in the collector. This avoids the job
|
|
# failing on its own known, human-gated gap and that failure being
|
|
# counted as a "repeated-workflow-failure" product signal on a later
|
|
# run (also guarded against directly via SELF_WORKFLOW_* exclusion
|
|
# in the collector's workflow-failure count).
|
|
if: steps.collect.outcome == 'failure'
|
|
run: |
|
|
echo "::error::A required product-intelligence source was unavailable. See the report summary for the exact source and error."
|
|
exit 1
|