188 lines
9.1 KiB
YAML
188 lines
9.1 KiB
YAML
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@v8
|
|
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 });
|
|
}
|