name: Post Web Flasher Link Comment on: workflow_run: workflows: [CI] types: [completed] permissions: pull-requests: write actions: read jobs: post-flasher-link: if: > github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.repository == 'meshtastic/firmware' continue-on-error: true runs-on: ubuntu-latest steps: - name: Post or update web flasher link comment uses: actions/github-script@v8 with: script: | const marker = ''; const run = context.payload.workflow_run; const { owner, repo } = context.repo; // Resolve the PR number (run.pull_requests is empty for fork PRs) let prNumber = run.pull_requests?.[0]?.number; if (!prNumber) { const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: run.head_sha, }); prNumber = (prs.find((pr) => pr.head.sha === run.head_sha) ?? prs[0])?.number; } if (!prNumber) { core.info('No pull request associated with this run; skipping.'); return; } const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); // Only comment on PRs from contributors and members const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; if (!allowedAssociations.includes(pr.author_association)) { core.info(`Author association ${pr.author_association} is not eligible; skipping.`); return; } if (pr.state !== 'open') { core.info('Pull request is not open; skipping.'); return; } if (pr.head.sha !== run.head_sha) { core.info('Run is for an outdated commit; skipping.'); return; } // Require at least one per-arch firmware artifact from gather-artifacts const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { owner, repo, run_id: run.id, per_page: 100, }); const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/; const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired); if (archArtifacts.length === 0) { core.info('No per-arch firmware artifacts found; skipping.'); return; } const version = archRe.exec(archArtifacts[0].name)[2]; const expiresAt = archArtifacts[0].expires_at ? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10) : null; // Per-board deep links from manifest-{platform}-{board}-{version} artifacts const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const boardRe = new RegExp(`^manifest-([a-z0-9]+)-(.+)-${escapedVersion}$`); const boards = artifacts .map((a) => boardRe.exec(a.name)) .filter(Boolean) .map((m) => ({ platform: m[1], board: m[2] })) .sort((a, b) => a.board.localeCompare(b.board)); const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`; const boardLines = boards .map((b) => `| [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`) .join('\n'); const body = [ marker, '## 🔦 Try this PR in the Web Flasher', '', `[**Flash firmware \`${version}\`** (commit \`${run.head_sha.slice(0, 7)}\`)](${flasherUrl})`, '', '> [!WARNING]', '> This is an automated, unreviewed CI test build. Back up your device configuration', '> before flashing, and only flash devices you are able to recover.', '', `
Boards built by this PR (${boards.length})`, '', '| Board | Platform |', '| --- | --- |', boardLines, '', '
', '', `*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for ${run.head_sha}.*`, ].join('\n'); // Sticky comment: update in place when the marker is found const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100, }); const existing = comments.find((c) => c.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: prNumber, body }); }