Post Web Flasher Link Comment #1262
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: 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 != 'cancelled' && | |
| github.repository == 'meshtastic/firmware' | |
| continue-on-error: true | |
| runs-on: ubuntu-latest | |
| steps: | |
| # Per-board manifests carry the firmware's own metadata (activelySupported, | |
| # displayName, ...) generated from each target's custom_meshtastic_* config. | |
| - name: Download board manifests | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| pattern: manifest-* | |
| path: ./manifests | |
| merge-multiple: true | |
| - name: Post or update web flasher link comment | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const marker = '<!-- web-flasher-link -->'; | |
| const run = context.payload.workflow_run; | |
| const { owner, repo } = context.repo; | |
| // Resolve the PR by matching the run's head SHA against the repo's open | |
| // PRs. workflow_run.pull_requests is empty for fork PRs, and | |
| // listPullRequestsAssociatedWithCommit won't return an open fork PR by | |
| // its head commit - but pulls.list includes fork PRs. Matching on head | |
| // SHA also enforces that the run is for the PR's current commit, so stale | |
| // re-runs of an outdated commit won't match. | |
| const openPrs = await github.paginate(github.rest.pulls.list, { | |
| owner, repo, state: 'open', per_page: 100, | |
| }); | |
| const pr = openPrs.find((p) => p.head.sha === run.head_sha); | |
| if (!pr) { | |
| core.info(`No open pull request matches commit ${run.head_sha}; skipping.`); | |
| return; | |
| } | |
| const prNumber = pr.number; | |
| // Restrict to trusted authors. NOTE: author_association is computed for | |
| // the GITHUB_TOKEN, which cannot see *private/concealed* org memberships - | |
| // those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER | |
| // alone silently excludes most maintainers. We allow the trusted set the | |
| // token can actually identify (members, collaborators, and anyone with a | |
| // previously merged PR). For strict members-only you'd need an org-read | |
| // App/PAT token to call orgs.checkMembershipForUser. | |
| const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; | |
| if (!allowedAssociations.includes(pr.author_association)) { | |
| core.info(`Author association ${pr.author_association} is not trusted; 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; | |
| // Read each built board's manifest (.mt.json). activelySupported, | |
| // displayName and architecture come straight from the board's | |
| // custom_meshtastic_* platformio config, so the list is in sync with | |
| // the firmware itself - no external device database needed. | |
| const fs = require('fs'); | |
| let boards = []; | |
| try { | |
| boards = fs.readdirSync('./manifests') | |
| .filter((f) => f.endsWith('.mt.json')) | |
| .map((f) => { | |
| try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); } | |
| catch { return null; } | |
| }) | |
| .filter((m) => m && m.activelySupported === true && m.platformioTarget) | |
| .map((m) => ({ | |
| board: m.platformioTarget, | |
| platform: m.architecture || '', | |
| // displayName is maintainer-authored text; escape table-breaking pipes | |
| displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'), | |
| image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '', | |
| })) | |
| .sort((a, b) => a.board.localeCompare(b.board)); | |
| } catch (e) { | |
| core.warning(`Could not read board manifests: ${e.message}`); | |
| } | |
| const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`; | |
| // Device illustrations are served by the flasher from the same image | |
| // names the manifest declares (custom_meshtastic_images). The flasher | |
| // serves its SPA shell (HTML, 200) for unknown paths, so confirm each | |
| // image really resolves to an image before linking it. | |
| const imageBase = 'https://flasher.meshtastic.org/img/devices/'; | |
| await Promise.all(boards.map(async (b) => { | |
| if (!b.image) return; | |
| try { | |
| const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`); | |
| const type = res.headers.get('content-type') || ''; | |
| if (!res.ok || !type.startsWith('image/')) b.image = ''; | |
| } catch { b.image = ''; } | |
| })); | |
| const boardLines = boards | |
| .map((b) => { | |
| const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : ''; | |
| return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`; | |
| }) | |
| .join('\n'); | |
| // Shields.io badges. Only non-user-controlled, charset-constrained values | |
| // (version, commit sha, counts, dates) go into badge URLs - never board | |
| // names or the PR title - so the rendered comment cannot be spoofed. | |
| const shieldText = (s) => | |
| encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_')); | |
| const shield = (label, message, color) => | |
| `https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`; | |
| const buttonUrl = | |
| `https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`; | |
| const badges = [ | |
| `})`, | |
| `, '2C2D3C')})`, | |
| `})`, | |
| ]; | |
| if (expiresAt) badges.push(`})`); | |
| // Only render the board table when there are supported boards to list | |
| const boardTable = boards.length > 0 ? [ | |
| `<details><summary>Supported boards built by this PR (${boards.length})</summary>`, | |
| '', | |
| '| | Device | Board | Platform |', | |
| '| --- | --- | --- | --- |', | |
| boardLines, | |
| '', | |
| '</details>', | |
| '', | |
| ] : []; | |
| const body = [ | |
| marker, | |
| '## ⚡ Try this PR in the Web Flasher', | |
| '', | |
| `[](${flasherUrl})`, | |
| '', | |
| badges.join(' '), | |
| '', | |
| '> [!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.', | |
| '', | |
| ...boardTable, | |
| `*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`, | |
| ].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 }); | |
| } |