From 07e011fa04660ec5717e3b1b4147c82ca92be044 Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:42:28 -0400 Subject: [PATCH 1/9] Add a workflow that applies approved plugin icons Adding the `icon-approved` label to an icon request (or to a verification request that attached an icon) downloads the icon, resizes it to a 100x100 PNG, adds it to icons/ and plugin-icons.json (replacing the existing icon if the plugin has one), regenerates assets/ via sort-json-files, commits `icon: ` to latest, and reports back on the issue. Icon requests are closed once applied. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/icon-request.yml | 143 ++++++++++++++++++++ scripts/apply-icon-request.js | 204 +++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 .github/workflows/icon-request.yml create mode 100644 scripts/apply-icon-request.js diff --git a/.github/workflows/icon-request.yml b/.github/workflows/icon-request.yml new file mode 100644 index 00000000..3231a52d --- /dev/null +++ b/.github/workflows/icon-request.yml @@ -0,0 +1,143 @@ +name: Icon Request + +# A maintainer approves an icon by adding the `icon-approved` label to an icon request +# (or to a verification request that attached an icon). This downloads the icon, +# makes it a 100x100 PNG, adds it to icons/ and plugin-icons.json, regenerates +# assets/, commits to `latest`, and reports back on the issue. +on: + issues: + types: + - labeled + +permissions: + contents: write + issues: write + +# One at a time, so two approvals never race on plugin-icons.json and assets/ +concurrency: + group: icon-request + cancel-in-progress: false + +jobs: + apply-icon: + if: github.event.label.name == 'icon-approved' + runs-on: ubuntu-latest + steps: + - name: Check the approver can write to the repo + id: permission + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.sender.login, + }); + if (!['admin', 'maintain', 'write'].includes(data.permission)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'icon-approved', + }); + core.setFailed(`${context.payload.sender.login} has ${data.permission} permission; icon-approved needs write.`); + } + + - uses: actions/checkout@v7 + with: + ref: latest + + - uses: actions/setup-node@v7 + with: + node-version: v24 + + - name: Install sharp + run: npm install --no-save sharp@0.35.4 + + - name: Apply the icon and commit + id: apply + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + RESULT_FILE: ${{ runner.temp }}/icon-result.json + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + # Rebuilding from a fresh `latest` on each attempt means a push that loses a + # race to another commit is simply redone on top of it. + for attempt in 1 2 3; do + git fetch --quiet origin latest + git reset --quiet --hard origin/latest + + node scripts/apply-icon-request.js + if [[ "$(jq -r .ok "$RESULT_FILE")" != "true" ]]; then + exit 0 + fi + PLUGIN=$(jq -r .plugin "$RESULT_FILE") + ICON_PATH=$(jq -r .iconPath "$RESULT_FILE") + + # Sorts the JSON files and regenerates assets/plugins*.json + node scripts/sort-json-files.js + + if git diff --quiet && [[ -z "$(git ls-files --others --exclude-standard icons)" ]]; then + jq '. + {unchanged: true}' "$RESULT_FILE" > "$RESULT_FILE.tmp" && mv "$RESULT_FILE.tmp" "$RESULT_FILE" + exit 0 + fi + + git add -- "$ICON_PATH" plugin-icons.json assets + git commit --quiet -m "icon: $PLUGIN" -m "From homebridge/plugins#$ISSUE_NUMBER" + if git push --quiet origin HEAD:latest; then + jq --arg sha "$(git rev-parse HEAD)" '. + {sha: $sha}' "$RESULT_FILE" > "$RESULT_FILE.tmp" && mv "$RESULT_FILE.tmp" "$RESULT_FILE" + exit 0 + fi + echo "Push failed (attempt $attempt), retrying on the new latest" + sleep $((attempt * 5)) + done + echo '{"ok": false, "error": "The icon could not be pushed to `latest` after 3 attempts; see the workflow log."}' > "$RESULT_FILE" + + - name: Report on the issue + if: always() && steps.permission.outcome == 'success' + uses: actions/github-script@v9 + env: + RESULT_FILE: ${{ runner.temp }}/icon-result.json + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('node:fs'); + const issue = { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }; + const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + let result; + try { + result = JSON.parse(fs.readFileSync(process.env.RESULT_FILE, 'utf8')); + } catch { + result = { ok: false, error: 'The icon workflow failed before it produced a result.' }; + } + + let body; + if (!result.ok) { + body = `The icon could not be added automatically:\n\n> ${result.error}\n\n` + + 'Once this is fixed (for example by editing the icon in the issue), a maintainer can add the `icon-approved` label again.\n\n' + + `[Workflow run](${run})`; + } else if (result.unchanged) { + body = `The icon for \`${result.plugin}\` was already up to date; nothing was changed.`; + } else { + const resized = result.width === 100 && result.height === 100 ? '' : ` (resized from ${result.width} × ${result.height} px)`; + body = `${result.updated ? 'Updated' : 'Added'} the icon for \`${result.plugin}\`${resized} in ${result.sha.slice(0, 7)}. ` + + 'It should show in the Homebridge UI within the hour.\n\n' + + `${result.plugin} icon`; + } + await github.rest.issues.createComment({ ...issue, body }); + + // Clear the approval either way, so adding it again re-runs this + try { + await github.rest.issues.removeLabel({ ...issue, name: 'icon-approved' }); + } catch {} + + // An icon request is done once its icon is in; a verification request stays open for review + const labels = context.payload.issue.labels.map(l => l.name); + if (result.ok && labels.includes('request-icon')) { + await github.rest.issues.update({ ...issue, state: 'closed', state_reason: 'completed' }); + } diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js new file mode 100644 index 00000000..4b2ca714 --- /dev/null +++ b/scripts/apply-icon-request.js @@ -0,0 +1,204 @@ +/** + * Applies the icon from an approved icon request (or a verification request that + * attached one) to the repo: downloads the image, makes it a 100x100 PNG in + * icons/, and adds it to plugin-icons.json. Run by the Icon Request workflow. + * + * Input: ISSUE_BODY env var (the issue form body, untrusted). + * Output: a JSON result on stdout and in RESULT_FILE when set: + * { ok, plugin, iconPath, updated, source, width, height } or { ok: false, error } + */ + +import { Buffer } from 'node:buffer' +import fs from 'node:fs' +import process from 'node:process' + +import sharp from 'sharp' + +const ICON_SIZE = 100 +const MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024 +const MIN_SOURCE_SIZE = 64 +// Allowed difference between width and height before an image counts as not square +const SQUARE_TOLERANCE = 0.02 + +// Only fetch images GitHub hosts for issues and repos; redirects from these hosts +// (to signed storage URLs) are followed +const ALLOWED_HOSTS = new Set([ + 'github.com', + 'private-user-images.githubusercontent.com', + 'raw.githubusercontent.com', + 'user-images.githubusercontent.com', +]) + +// Same rule Homebridge uses to recognise a plugin package +const PLUGIN_NAME = /^(?:@[a-z0-9][\w.-]*\/)?homebridge-[a-z0-9][\w.-]*$/ + +class IconRequestError extends Error {} + +// Returns the text under a `### ` line, up to the next heading +function getSection(body, heading) { + const lines = body.replace(/\r\n/g, '\n').split('\n') + const start = lines.findIndex(line => line.trim().toLowerCase().startsWith(`### ${heading.toLowerCase()}`)) + if (start === -1) { + return '' + } + const rest = lines.slice(start + 1) + const end = rest.findIndex(line => /^#{2,3} /.test(line.trim())) + return (end === -1 + ? rest + : rest.slice(0, end)).join('\n').trim() +} + +function getPluginName(body) { + const section = getSection(body, 'Plugin Name') + const name = (section.split('\n').find(line => line.trim()) || '') + .trim() + .replace(/^`+|`+$/g, '') + .toLowerCase() + if (!PLUGIN_NAME.test(name)) { + throw new IconRequestError(`Could not read a valid plugin name from the "Plugin Name" field (found \`${name || 'nothing'}\`).`) + } + return name +} + +function getIconUrl(body) { + const section = getSection(body, 'Plugin Icon') + // Embedded images ( or ![alt](...)) win over bare links, which are + // often to the npm page or repo; a bare link only counts if it looks like an image + const embedded = [ + ...section.matchAll(/]*\ssrc="([^"]+)"|!\[[^\]]*\]\(([^)\s]+)\)/g), + ] + .map(match => match[1] || match[2]) + const linked = [ + ...section.matchAll(/https:\/\/[^\s)<>"]+/g), + ] + .map(match => match[0].replace(/[.,]+$/, '')) + .filter(link => /\.(?:png|jpe?g|webp)(?:\?|$)/i.test(link) || link.includes('/user-attachments/assets/')) + const candidates = [ + ...embedded, + ...linked, + ] + .map((link) => { + try { + return new URL(link) + } catch { + return null + } + }) + .filter(Boolean) + if (!candidates.length) { + throw new IconRequestError('No image was found in the "Plugin Icon" field.') + } + + const url = candidates.find(candidate => candidate.protocol === 'https:' && ALLOWED_HOSTS.has(candidate.hostname)) + if (!url) { + throw new IconRequestError(`Icons must be attached to the issue or hosted on GitHub; \`${candidates[0].hostname}\` is not accepted.`) + } + // A github.com///blob// link is a web page; fetch the raw file + const blob = url.hostname === 'github.com' && url.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/) + if (blob) { + return `https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}` + } + return url.toString() +} + +async function download(url) { + const res = await fetch(url, { redirect: 'follow' }) + if (!res.ok) { + throw new IconRequestError(`Downloading the icon failed with HTTP ${res.status}.`) + } + if (Number(res.headers.get('content-length')) > MAX_DOWNLOAD_BYTES) { + throw new IconRequestError('The icon file is larger than 5 MB.') + } + const buffer = Buffer.from(await res.arrayBuffer()) + if (buffer.length > MAX_DOWNLOAD_BYTES) { + throw new IconRequestError('The icon file is larger than 5 MB.') + } + return buffer +} + +async function toIcon(buffer) { + let meta + try { + meta = await sharp(buffer).metadata() + } catch { + throw new IconRequestError('The icon could not be read as an image. Please attach a PNG.') + } + if (!['png', 'jpeg', 'webp'].includes(meta.format)) { + throw new IconRequestError(`The icon is a \`${meta.format}\` file. Please attach a square PNG.`) + } + const { width, height } = meta + if (Math.abs(width - height) / Math.max(width, height) > SQUARE_TOLERANCE) { + throw new IconRequestError(`The icon must be square; this one is ${width} × ${height} px.`) + } + if (Math.min(width, height) < MIN_SOURCE_SIZE) { + throw new IconRequestError(`The icon is too small (${width} × ${height} px); it should be about ${ICON_SIZE} × ${ICON_SIZE} px.`) + } + const png = await sharp(buffer) + .resize(ICON_SIZE, ICON_SIZE, { fit: 'cover' }) + .png({ compressionLevel: 9 }) + .toBuffer() + return { png, width, height } +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')) +} + +async function main() { + const body = process.env.ISSUE_BODY || '' + const plugin = getPluginName(body) + + const verified = [ + ...readJson('verified-plugins.json'), + ...readJson('verified-plus-plugins.json'), + ] + if (!verified.includes(plugin)) { + // scripts/sort-json-files.js drops icon entries for plugins on neither list + throw new IconRequestError(`\`${plugin}\` is not on the verified list, and icons are only shown for verified plugins.`) + } + + const source = getIconUrl(body) + const { png, width, height } = await toIcon(await download(source)) + + const icons = readJson('plugin-icons.json') + const iconPath = icons[plugin] || `icons/${plugin.split('/').pop()}.png` + const owner = Object.keys(icons).find(key => key !== plugin && icons[key] === iconPath) + if (owner) { + throw new IconRequestError(`\`${iconPath}\` is already used by \`${owner}\`; this one needs to be added by hand.`) + } + + const updated = fs.existsSync(iconPath) + fs.writeFileSync(iconPath, png) + icons[plugin] = iconPath + fs.writeFileSync('plugin-icons.json', `${JSON.stringify(icons, null, 2)}\n`) + + return { + ok: true, + plugin, + iconPath, + updated, + source, + width, + height, + } +} + +let result +try { + result = await main() +} catch (e) { + if (!(e instanceof IconRequestError)) { + console.error(e) + } + result = { + ok: false, + error: e instanceof IconRequestError + ? e.message + : 'The icon could not be applied because of an unexpected error; see the workflow log.', + } +} + +console.log(JSON.stringify(result, null, 2)) +if (process.env.RESULT_FILE) { + fs.writeFileSync(process.env.RESULT_FILE, JSON.stringify(result)) +} From 6fd9e0b30fe0188f3a247f79556ffa8d1a1e0bc4 Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:57:36 -0400 Subject: [PATCH 2/9] Allow icons for unverified plugins Icons no longer require verification. sort-json-files.js keeps icon entries for any plugin whose icon file exists and adds those plugins to assets/plugins*.json, so the UI (which shows any plugin with an `i` entry) picks them up. The icon workflow drops its verified check and instead confirms the package exists on npm. The README and icon request template no longer say an icon needs a verified plugin. Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/2_icon-request.yml | 6 ++--- README.md | 3 +-- scripts/apply-icon-request.js | 15 +++++++------ scripts/sort-json-files.js | 27 ++++++++++------------- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/2_icon-request.yml b/.github/ISSUE_TEMPLATE/2_icon-request.yml index 04d46a3b..79b7be12 100644 --- a/.github/ISSUE_TEMPLATE/2_icon-request.yml +++ b/.github/ISSUE_TEMPLATE/2_icon-request.yml @@ -1,5 +1,5 @@ name: Plugin Icon Request -description: Request an icon for your verified plugin. +description: Request an icon for your plugin. title: 'Plugin Icon Request: homebridge-' labels: - request-icon @@ -10,8 +10,8 @@ body: - type: markdown attributes: value: | - - A plugin must be **verified** before requesting an icon. - - Icons can only be request by the developer of a plugin. + - Icons can only be requested by the developer of a plugin. + - Your plugin does not need to be verified to have an icon. - type: input id: plugin-name attributes: diff --git a/README.md b/README.md index e48673b2..e2e8b9c6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Information and resources for Homebridge plugins. Create [an issue](https://github.com/homebridge/plugins/issues/new/choose) to: - request verification for your plugin -- request an icon for your verified plugin +- request an icon for your plugin - request a transfer into the Homebridge organization (you can choose whether to continue maintaining the plugin) - request to maintain an unmaintained plugin listed [here](https://github.com/homebridge/plugins/wiki/Unmaintained-Plugins#available-plugins) @@ -34,7 +34,6 @@ The **Verified By Homebridge** program allows plugin developers to get their plu - The **Verified** shield icon will turn green next to your plugin in the [Homebridge UI](https://github.com/homebridge/homebridge-config-ui-x). - The [**Donate**](https://github.com/homebridge/homebridge-config-ui-x/wiki/Developers:-Donation-Links) heart icon will turn pink and enable on your plugin tile in the Homebridge UI. - Your plugin is bumped to the top of the search results in the Homebridge UI. -- You can optionally upload an icon for your plugin which will be displayed in the Homebridge UI. ### Requirements diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index 4b2ca714..c3fcdd50 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -148,13 +148,14 @@ async function main() { const body = process.env.ISSUE_BODY || '' const plugin = getPluginName(body) - const verified = [ - ...readJson('verified-plugins.json'), - ...readJson('verified-plus-plugins.json'), - ] - if (!verified.includes(plugin)) { - // scripts/sort-json-files.js drops icon entries for plugins on neither list - throw new IconRequestError(`\`${plugin}\` is not on the verified list, and icons are only shown for verified plugins.`) + // Any plugin may have an icon, but it must be a real package, so a typo in the + // name does not add an icon nothing will ever use + const npm = await fetch(`https://registry.npmjs.org/${plugin.replace('/', '%2F')}`, { method: 'HEAD' }) + if (npm.status === 404) { + throw new IconRequestError(`\`${plugin}\` was not found on npm. Please check the "Plugin Name" field.`) + } + if (!npm.ok) { + throw new IconRequestError(`Could not check \`${plugin}\` on npm (HTTP ${npm.status}); please try again later.`) } const source = getIconUrl(body) diff --git a/scripts/sort-json-files.js b/scripts/sort-json-files.js index 0f1c9686..1223d87b 100644 --- a/scripts/sort-json-files.js +++ b/scripts/sort-json-files.js @@ -46,19 +46,12 @@ const hasScopeSorted = hasScope.sort((a, b) => a.from.localeCompare(b.from)) const hasScopeKeys = hasScopeSorted.map(plugin => plugin.from) fs.writeFileSync('has-scope-plugins.json', `${JSON.stringify(hasScopeSorted, null, 2)}\n`) -// A plugin's icon is only shown when the plugin is verified (or verified-plus), -// so drop icon entries for plugins on neither list, and entries whose icon -// file is missing from the repo. +// Any plugin may have an icon, verified or not. Drop entries whose icon file is +// missing from the repo. const icons = JSON.parse(fs.readFileSync('plugin-icons.json', 'utf8')) - -fs.writeFileSync('plugin-icons.json', `${JSON.stringify(Object.keys(icons) +const iconsSorted = Object.keys(icons) .filter((key) => { - const iconFile = icons[key] - if (!verified.includes(key) && !verifiedPlus.includes(key)) { - console.log(` - Ignoring icon for ${key} because it is not in the verified or verified-plus lists`) - return false - } - if (!fs.existsSync(`./${iconFile}`)) { + if (!fs.existsSync(`./${icons[key]}`)) { console.log(` - Ignoring icon for ${key} because the icon file does not exist`) return false } @@ -68,8 +61,11 @@ fs.writeFileSync('plugin-icons.json', `${JSON.stringify(Object.keys(icons) .reduce((obj, key) => { obj[key] = icons[key] return obj - }, {}), null, 2)}\n`) + }, {}) + +fs.writeFileSync('plugin-icons.json', `${JSON.stringify(iconsSorted, null, 2)}\n`) +const iconsSortedKeys = Object.keys(iconsSorted) const authorsSortedKeys = Object.keys(authorsSorted) const namesSortedKeys = Object.keys(namesSorted) const changelogsSortedKeys = Object.keys(changelogsSorted) @@ -83,6 +79,8 @@ const fullJson = [ ...authorsSortedKeys, ...namesSortedKeys, ...changelogsSortedKeys, + // An unverified plugin with an icon may be on no other list + ...iconsSortedKeys, ] .sort() .reduce((obj, key) => { @@ -91,9 +89,7 @@ const fullJson = [ ? namesSorted[key] : null, hidden: hidden.includes(key), - icon: (verified.includes(key) || verifiedPlus.includes(key)) && fs.existsSync(`./${icons[key]}`) - ? icons[key] - : null, + icon: iconsSorted[key] || null, unmaintained: unmaintained.includes(key), newScope: hasScopeKeys.includes(key) ? hasScopeSorted.find(plugin => plugin.from === key) @@ -179,6 +175,7 @@ console.log(`- Scoped Total: ${fullArray.filter(plugin => plugin.scoped).length} console.log(`- Has New Scope Total: ${fullArray.filter(plugin => plugin.newScope).length}`) console.log(`- Verified With Icon: ${fullArray.filter(plugin => plugin.verified && plugin.icon).length}`) console.log(`- Verified Without Icon: ${fullArray.filter(plugin => plugin.verified && !plugin.icon).length}`) +console.log(`- Unverified With Icon: ${fullArray.filter(plugin => !plugin.verified && !plugin.verifiedPlus && plugin.icon).length}`) console.log(`- Verified Total: ${fullArray.filter(plugin => plugin.verified).length}`) console.log(`- Verified Plus Total: ${fullArray.filter(plugin => plugin.verifiedPlus).length}`) console.log('-----------------------------') From 99737cd0153d075b60f3cd4c03a090ee89cd8d8d Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:59:03 -0400 Subject: [PATCH 3/9] Only apply icons from icon requests The icon workflow now runs only when `icon-approved` is added to an issue labelled `request-icon`, and closes the request once the icon is in. Icons attached to verification requests need their own icon request. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/icon-request.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/icon-request.yml b/.github/workflows/icon-request.yml index 3231a52d..10eae05a 100644 --- a/.github/workflows/icon-request.yml +++ b/.github/workflows/icon-request.yml @@ -1,9 +1,10 @@ name: Icon Request # A maintainer approves an icon by adding the `icon-approved` label to an icon request -# (or to a verification request that attached an icon). This downloads the icon, -# makes it a 100x100 PNG, adds it to icons/ and plugin-icons.json, regenerates -# assets/, commits to `latest`, and reports back on the issue. +# (an issue labelled `request-icon`). This downloads the icon, makes it a 100x100 +# PNG, adds it to icons/ and plugin-icons.json, regenerates assets/, commits to +# `latest`, reports back on the issue and closes it. The label is ignored on any +# other issue. on: issues: types: @@ -20,7 +21,7 @@ concurrency: jobs: apply-icon: - if: github.event.label.name == 'icon-approved' + if: github.event.label.name == 'icon-approved' && contains(github.event.issue.labels.*.name, 'request-icon') runs-on: ubuntu-latest steps: - name: Check the approver can write to the repo @@ -136,8 +137,7 @@ jobs: await github.rest.issues.removeLabel({ ...issue, name: 'icon-approved' }); } catch {} - // An icon request is done once its icon is in; a verification request stays open for review - const labels = context.payload.issue.labels.map(l => l.name); - if (result.ok && labels.includes('request-icon')) { + // The request is done once its icon is in + if (result.ok) { await github.rest.issues.update({ ...issue, state: 'closed', state_reason: 'completed' }); } From 5d54e5f292c7bb93f1e5dc4cfb11044fa94a31c1 Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:05:11 -0400 Subject: [PATCH 4/9] Note stage 2 (verified requests) in the icon workflow Co-Authored-By: Claude Opus 5.5 --- .github/workflows/icon-request.yml | 6 ++++++ scripts/apply-icon-request.js | 10 +++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/icon-request.yml b/.github/workflows/icon-request.yml index 10eae05a..ab09d39d 100644 --- a/.github/workflows/icon-request.yml +++ b/.github/workflows/icon-request.yml @@ -5,6 +5,12 @@ name: Icon Request # PNG, adds it to icons/ and plugin-icons.json, regenerates assets/, commits to # `latest`, reports back on the issue and closes it. The label is ignored on any # other issue. +# +# Stage 2 (not yet built): also apply the icon attached to a verification request +# when a maintainer adds the `verified` label, leaving that issue's closing to the +# verification flow. scripts/apply-icon-request.js already reads the verification +# form's "Plugin Icon (Optional)" field, so this needs a second trigger here, not +# a new script. on: issues: types: diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index c3fcdd50..d9854ae6 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -1,7 +1,11 @@ /** - * Applies the icon from an approved icon request (or a verification request that - * attached one) to the repo: downloads the image, makes it a 100x100 PNG in - * icons/, and adds it to plugin-icons.json. Run by the Icon Request workflow. + * Applies the icon from an approved icon request to the repo: downloads the image, + * makes it a 100x100 PNG in icons/, and adds it to plugin-icons.json. Run by the + * Icon Request workflow. + * + * It also reads the verification request form's "Plugin Icon (Optional)" field, for + * stage 2: applying an attached icon when a verification request gets `verified`. + * The workflow does not trigger on that yet. * * Input: ISSUE_BODY env var (the issue form body, untrusted). * Output: a JSON result on stdout and in RESULT_FILE when set: From b058ae87d85a4195be4bd2c10fc21691eb4969af Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:07:29 -0400 Subject: [PATCH 5/9] Leave the README and icon request template unchanged Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/2_icon-request.yml | 6 +++--- README.md | 3 ++- scripts/apply-icon-request.js | 4 ++-- scripts/sort-json-files.js | 6 ++---- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/2_icon-request.yml b/.github/ISSUE_TEMPLATE/2_icon-request.yml index 79b7be12..04d46a3b 100644 --- a/.github/ISSUE_TEMPLATE/2_icon-request.yml +++ b/.github/ISSUE_TEMPLATE/2_icon-request.yml @@ -1,5 +1,5 @@ name: Plugin Icon Request -description: Request an icon for your plugin. +description: Request an icon for your verified plugin. title: 'Plugin Icon Request: homebridge-' labels: - request-icon @@ -10,8 +10,8 @@ body: - type: markdown attributes: value: | - - Icons can only be requested by the developer of a plugin. - - Your plugin does not need to be verified to have an icon. + - A plugin must be **verified** before requesting an icon. + - Icons can only be request by the developer of a plugin. - type: input id: plugin-name attributes: diff --git a/README.md b/README.md index e2e8b9c6..e48673b2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Information and resources for Homebridge plugins. Create [an issue](https://github.com/homebridge/plugins/issues/new/choose) to: - request verification for your plugin -- request an icon for your plugin +- request an icon for your verified plugin - request a transfer into the Homebridge organization (you can choose whether to continue maintaining the plugin) - request to maintain an unmaintained plugin listed [here](https://github.com/homebridge/plugins/wiki/Unmaintained-Plugins#available-plugins) @@ -34,6 +34,7 @@ The **Verified By Homebridge** program allows plugin developers to get their plu - The **Verified** shield icon will turn green next to your plugin in the [Homebridge UI](https://github.com/homebridge/homebridge-config-ui-x). - The [**Donate**](https://github.com/homebridge/homebridge-config-ui-x/wiki/Developers:-Donation-Links) heart icon will turn pink and enable on your plugin tile in the Homebridge UI. - Your plugin is bumped to the top of the search results in the Homebridge UI. +- You can optionally upload an icon for your plugin which will be displayed in the Homebridge UI. ### Requirements diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index d9854ae6..9c689fa0 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -152,8 +152,8 @@ async function main() { const body = process.env.ISSUE_BODY || '' const plugin = getPluginName(body) - // Any plugin may have an icon, but it must be a real package, so a typo in the - // name does not add an icon nothing will ever use + // The plugin must be a real package, so a typo in the name does not add an + // icon nothing will ever use const npm = await fetch(`https://registry.npmjs.org/${plugin.replace('/', '%2F')}`, { method: 'HEAD' }) if (npm.status === 404) { throw new IconRequestError(`\`${plugin}\` was not found on npm. Please check the "Plugin Name" field.`) diff --git a/scripts/sort-json-files.js b/scripts/sort-json-files.js index 1223d87b..7ee99192 100644 --- a/scripts/sort-json-files.js +++ b/scripts/sort-json-files.js @@ -46,8 +46,7 @@ const hasScopeSorted = hasScope.sort((a, b) => a.from.localeCompare(b.from)) const hasScopeKeys = hasScopeSorted.map(plugin => plugin.from) fs.writeFileSync('has-scope-plugins.json', `${JSON.stringify(hasScopeSorted, null, 2)}\n`) -// Any plugin may have an icon, verified or not. Drop entries whose icon file is -// missing from the repo. +// Drop icon entries whose icon file is missing from the repo. const icons = JSON.parse(fs.readFileSync('plugin-icons.json', 'utf8')) const iconsSorted = Object.keys(icons) .filter((key) => { @@ -79,7 +78,7 @@ const fullJson = [ ...authorsSortedKeys, ...namesSortedKeys, ...changelogsSortedKeys, - // An unverified plugin with an icon may be on no other list + // A plugin with an icon may be on no other list ...iconsSortedKeys, ] .sort() @@ -175,7 +174,6 @@ console.log(`- Scoped Total: ${fullArray.filter(plugin => plugin.scoped).length} console.log(`- Has New Scope Total: ${fullArray.filter(plugin => plugin.newScope).length}`) console.log(`- Verified With Icon: ${fullArray.filter(plugin => plugin.verified && plugin.icon).length}`) console.log(`- Verified Without Icon: ${fullArray.filter(plugin => plugin.verified && !plugin.icon).length}`) -console.log(`- Unverified With Icon: ${fullArray.filter(plugin => !plugin.verified && !plugin.verifiedPlus && plugin.icon).length}`) console.log(`- Verified Total: ${fullArray.filter(plugin => plugin.verified).length}`) console.log(`- Verified Plus Total: ${fullArray.filter(plugin => plugin.verifiedPlus).length}`) console.log('-----------------------------') From 23ffc2deea6959a2f497400bbaeda38bd07e1746 Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:11:42 -0400 Subject: [PATCH 6/9] Encode the whole package name for the npm lookup Fixes the CodeQL incomplete-escaping alert: replace('/', '%2F') only replaced the first slash. Co-Authored-By: Claude Opus 5.5 --- scripts/apply-icon-request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index 9c689fa0..dbb0b877 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -154,7 +154,7 @@ async function main() { // The plugin must be a real package, so a typo in the name does not add an // icon nothing will ever use - const npm = await fetch(`https://registry.npmjs.org/${plugin.replace('/', '%2F')}`, { method: 'HEAD' }) + const npm = await fetch(`https://registry.npmjs.org/${encodeURIComponent(plugin)}`, { method: 'HEAD' }) if (npm.status === 404) { throw new IconRequestError(`\`${plugin}\` was not found on npm. Please check the "Plugin Name" field.`) } From 99f22928e5b9889f633da1b5036729256d0115fe Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:20:06 -0400 Subject: [PATCH 7/9] Harden the icon workflow - Decode the untrusted image in a `prepare` job with read-only access and no stored git credentials; the `apply` job that pushes only gets the finished PNG and checks its header without decoding it. - Check the requester develops the plugin (repo owner, npm maintainer, or public member of the owning org); maintainers may request any. - Cap image size at 4096x4096 px, time out downloads after 30 s, stop reading past 5 MB, and give each job a timeout. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/icon-request.yml | 138 +++++++++++++++----- scripts/apply-icon-request.js | 198 +++++++++++++++++++++++------ 2 files changed, 268 insertions(+), 68 deletions(-) diff --git a/.github/workflows/icon-request.yml b/.github/workflows/icon-request.yml index ab09d39d..ed7d7b7d 100644 --- a/.github/workflows/icon-request.yml +++ b/.github/workflows/icon-request.yml @@ -6,6 +6,10 @@ name: Icon Request # `latest`, reports back on the issue and closes it. The label is ignored on any # other issue. # +# The untrusted image is downloaded and decoded in the `prepare` job, which has no +# write access and keeps no git credentials. Only the finished PNG is passed on to +# the `apply` job that pushes, which checks it again without decoding it. +# # Stage 2 (not yet built): also apply the icon attached to a verification request # when a maintainer adds the `verified` label, leaving that issue's closing to the # verification flow. scripts/apply-icon-request.js already reads the verification @@ -16,9 +20,7 @@ on: types: - labeled -permissions: - contents: write - issues: write +permissions: {} # One at a time, so two approvals never race on plugin-icons.json and assets/ concurrency: @@ -26,34 +28,58 @@ concurrency: cancel-in-progress: false jobs: - apply-icon: + authorize: if: github.event.label.name == 'icon-approved' && contains(github.event.issue.labels.*.name, 'request-icon') runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + outputs: + author_is_maintainer: ${{ steps.check.outputs.author_is_maintainer }} steps: - name: Check the approver can write to the repo - id: permission + id: check uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.payload.sender.login, - }); - if (!['admin', 'maintain', 'write'].includes(data.permission)) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: 'icon-approved', - }); - core.setFailed(`${context.payload.sender.login} has ${data.permission} permission; icon-approved needs write.`); + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const permissionOf = async (username) => { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...repo, username }); + return data.permission; + } catch { + return 'none'; + } + }; + const canWrite = permission => ['admin', 'maintain', 'write'].includes(permission); + + const approver = context.payload.sender.login; + const approverPermission = await permissionOf(approver); + if (!canWrite(approverPermission)) { + await github.rest.issues.removeLabel({ ...repo, issue_number: context.issue.number, name: 'icon-approved' }); + core.setFailed(`${approver} has ${approverPermission} permission; icon-approved needs write.`); + return; } + // Maintainers may request icons for plugins they do not develop + core.setOutput('author_is_maintainer', String(canWrite(await permissionOf(context.payload.issue.user.login)))); + + prepare: + needs: authorize + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + ok: ${{ steps.prepare.outputs.ok }} + result: ${{ steps.prepare.outputs.result }} + png: ${{ steps.prepare.outputs.png }} + steps: - uses: actions/checkout@v7 with: ref: latest + persist-credentials: false - uses: actions/setup-node@v7 with: @@ -62,13 +88,51 @@ jobs: - name: Install sharp run: npm install --no-save sharp@0.35.4 + - name: Check the request and convert the icon + id: prepare + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + AUTHOR_IS_MAINTAINER: ${{ needs.authorize.outputs.author_is_maintainer }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ICON_FILE: ${{ runner.temp }}/icon.png + RESULT_FILE: ${{ runner.temp }}/icon-result.json + run: | + node scripts/apply-icon-request.js prepare + echo "ok=$(jq -r .ok "$RESULT_FILE")" >> "$GITHUB_OUTPUT" + echo "result=$(jq -c . "$RESULT_FILE")" >> "$GITHUB_OUTPUT" + if [[ "$(jq -r .ok "$RESULT_FILE")" == "true" ]]; then + echo "png=$(base64 -w0 "$ICON_FILE")" >> "$GITHUB_OUTPUT" + fi + + apply: + needs: prepare + if: needs.prepare.outputs.ok == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + outputs: + result: ${{ steps.apply.outputs.result }} + steps: + - uses: actions/checkout@v7 + with: + ref: latest + + - uses: actions/setup-node@v7 + with: + node-version: v24 + - name: Apply the icon and commit id: apply env: - ISSUE_BODY: ${{ github.event.issue.body }} + PREPARED: ${{ needs.prepare.outputs.result }} + ICON_PNG: ${{ needs.prepare.outputs.png }} ISSUE_NUMBER: ${{ github.event.issue.number }} + ICON_FILE: ${{ runner.temp }}/icon.png RESULT_FILE: ${{ runner.temp }}/icon-result.json run: | + printf '%s' "$ICON_PNG" | base64 -d > "$ICON_FILE" git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' @@ -78,9 +142,9 @@ jobs: git fetch --quiet origin latest git reset --quiet --hard origin/latest - node scripts/apply-icon-request.js + node scripts/apply-icon-request.js commit if [[ "$(jq -r .ok "$RESULT_FILE")" != "true" ]]; then - exit 0 + break fi PLUGIN=$(jq -r .plugin "$RESULT_FILE") ICON_PATH=$(jq -r .iconPath "$RESULT_FILE") @@ -90,38 +154,54 @@ jobs: if git diff --quiet && [[ -z "$(git ls-files --others --exclude-standard icons)" ]]; then jq '. + {unchanged: true}' "$RESULT_FILE" > "$RESULT_FILE.tmp" && mv "$RESULT_FILE.tmp" "$RESULT_FILE" - exit 0 + break fi git add -- "$ICON_PATH" plugin-icons.json assets git commit --quiet -m "icon: $PLUGIN" -m "From homebridge/plugins#$ISSUE_NUMBER" if git push --quiet origin HEAD:latest; then jq --arg sha "$(git rev-parse HEAD)" '. + {sha: $sha}' "$RESULT_FILE" > "$RESULT_FILE.tmp" && mv "$RESULT_FILE.tmp" "$RESULT_FILE" - exit 0 + break fi echo "Push failed (attempt $attempt), retrying on the new latest" + if [[ $attempt == 3 ]]; then + echo '{"ok": false, "error": "The icon could not be pushed to `latest` after 3 attempts; see the workflow log."}' > "$RESULT_FILE" + fi sleep $((attempt * 5)) done - echo '{"ok": false, "error": "The icon could not be pushed to `latest` after 3 attempts; see the workflow log."}' > "$RESULT_FILE" - + echo "result=$(jq -c . "$RESULT_FILE")" >> "$GITHUB_OUTPUT" + + report: + needs: + - authorize + - prepare + - apply + if: always() && needs.authorize.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: - name: Report on the issue - if: always() && steps.permission.outcome == 'success' uses: actions/github-script@v9 env: - RESULT_FILE: ${{ runner.temp }}/icon-result.json + PREPARE_RESULT: ${{ needs.prepare.outputs.result }} + APPLY_RESULT: ${{ needs.apply.outputs.result }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const fs = require('node:fs'); const issue = { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }; const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; let result; try { - result = JSON.parse(fs.readFileSync(process.env.RESULT_FILE, 'utf8')); + result = JSON.parse(process.env.APPLY_RESULT || process.env.PREPARE_RESULT); } catch { result = { ok: false, error: 'The icon workflow failed before it produced a result.' }; } + if (result.ok && !result.sha && !result.unchanged) { + result = { ok: false, error: 'The icon was prepared but not committed; see the workflow log.' }; + } let body; if (!result.ok) { diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index dbb0b877..f38429d3 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -1,28 +1,42 @@ /** - * Applies the icon from an approved icon request to the repo: downloads the image, - * makes it a 100x100 PNG in icons/, and adds it to plugin-icons.json. Run by the - * Icon Request workflow. + * Applies the icon from an approved icon request to the repo. Run by the Icon Request + * workflow in two steps, in separate jobs, so the untrusted image is never decoded in + * a job that can push: + * + * prepare Reads the request, checks the plugin exists and that the requester is + * its developer, downloads the image and converts it to a 100x100 PNG. + * Runs without write access. + * Input: ISSUE_BODY (untrusted), ISSUE_AUTHOR, AUTHOR_IS_MAINTAINER, + * GITHUB_TOKEN (read-only), ICON_FILE (where to write the PNG) + * Output: { ok, plugin, source, width, height, requester } or { ok: false, error } + * + * commit Re-checks the prepared PNG without decoding it, then writes it to icons/ + * and adds it to plugin-icons.json. Runs in the job that pushes. + * Input: PREPARED (the prepare result), ICON_FILE + * Output: the prepare result plus { iconPath, updated }, or { ok: false, error } + * + * Both write their result as JSON to stdout and to RESULT_FILE when set. * * It also reads the verification request form's "Plugin Icon (Optional)" field, for * stage 2: applying an attached icon when a verification request gets `verified`. * The workflow does not trigger on that yet. - * - * Input: ISSUE_BODY env var (the issue form body, untrusted). - * Output: a JSON result on stdout and in RESULT_FILE when set: - * { ok, plugin, iconPath, updated, source, width, height } or { ok: false, error } */ import { Buffer } from 'node:buffer' import fs from 'node:fs' import process from 'node:process' -import sharp from 'sharp' - const ICON_SIZE = 100 const MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024 +const DOWNLOAD_TIMEOUT_MS = 30_000 +// Rejects images that are small on disk but decompress to something huge +const MAX_INPUT_PIXELS = 4096 * 4096 const MIN_SOURCE_SIZE = 64 // Allowed difference between width and height before an image counts as not square const SQUARE_TOLERANCE = 0.02 +// A 100x100 PNG from sharp is well under this; anything larger was not made by prepare +const MAX_ICON_BYTES = 200 * 1024 +const PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex') // Only fetch images GitHub hosts for issues and repos; redirects from these hosts // (to signed storage URLs) are followed @@ -105,65 +119,169 @@ function getIconUrl(body) { return url.toString() } -async function download(url) { - const res = await fetch(url, { redirect: 'follow' }) +// The GitHub account that owns the package's repository, from its npm `repository` field +function getRepoOwner(repository) { + const url = typeof repository === 'string' + ? repository + : repository?.url || '' + const match = url.match(/github\.com[/:]([\w.-]+)\//i) || url.match(/^(?:github:)?([\w.-]+)\/[\w.-]+$/) + return match + ? match[1].toLowerCase() + : null +} + +// Checks the package exists, and that the requester is its developer: the owner of +// its GitHub repo, an npm maintainer with the same username, or a public member of the +// org that owns the repo. Maintainers of this repo may request icons for any plugin. +async function checkRequester(plugin) { + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(plugin)}/latest`, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }) + if (res.status === 404) { + throw new IconRequestError(`\`${plugin}\` was not found on npm. Please check the "Plugin Name" field.`) + } if (!res.ok) { - throw new IconRequestError(`Downloading the icon failed with HTTP ${res.status}.`) + throw new IconRequestError(`Could not check \`${plugin}\` on npm (HTTP ${res.status}); please try again later.`) + } + const pkg = await res.json() + + const author = (process.env.ISSUE_AUTHOR || '').toLowerCase() + if (process.env.AUTHOR_IS_MAINTAINER === 'true') { + return 'a maintainer of this repo' + } + + const owner = getRepoOwner(pkg.repository) + if (owner && owner === author) { + return 'the owner of the plugin\'s repository' } - if (Number(res.headers.get('content-length')) > MAX_DOWNLOAD_BYTES) { - throw new IconRequestError('The icon file is larger than 5 MB.') + const maintainers = (pkg.maintainers || []).map(m => String(m.name || '').toLowerCase()) + if (maintainers.includes(author)) { + return 'an npm maintainer of the plugin' } - const buffer = Buffer.from(await res.arrayBuffer()) - if (buffer.length > MAX_DOWNLOAD_BYTES) { - throw new IconRequestError('The icon file is larger than 5 MB.') + if (owner) { + const member = await fetch(`https://api.github.com/orgs/${encodeURIComponent(owner)}/members/${encodeURIComponent(author)}`, { + headers: process.env.GITHUB_TOKEN + ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}, + redirect: 'manual', + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }) + if (member.status === 204) { + return `a member of ${owner}` + } + } + + throw new IconRequestError(`Could not confirm that @${process.env.ISSUE_AUTHOR} develops \`${plugin}\` ` + + `(repository owner: ${owner || 'unknown'}; npm maintainers: ${maintainers.join(', ') || 'none'}). ` + + 'Icons can only be requested by the plugin\'s developer; a maintainer can add this one by hand.') +} + +async function download(url) { + let res + try { + res = await fetch(url, { + redirect: 'follow', + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }) + if (!res.ok) { + throw new IconRequestError(`Downloading the icon failed with HTTP ${res.status}.`) + } + if (Number(res.headers.get('content-length')) > MAX_DOWNLOAD_BYTES) { + throw new IconRequestError('The icon file is larger than 5 MB.') + } + // Stop reading as soon as the limit is passed, whatever the server claimed + const chunks = [] + let size = 0 + for await (const chunk of res.body) { + size += chunk.length + if (size > MAX_DOWNLOAD_BYTES) { + throw new IconRequestError('The icon file is larger than 5 MB.') + } + chunks.push(chunk) + } + return Buffer.concat(chunks) + } catch (e) { + if (e?.name === 'TimeoutError') { + throw new IconRequestError('Downloading the icon timed out.') + } + throw e } - return buffer } async function toIcon(buffer) { + // Only loaded here, so the commit step never loads an image decoder + const { default: sharp } = await import('sharp') + const options = { limitInputPixels: MAX_INPUT_PIXELS } + let meta try { - meta = await sharp(buffer).metadata() - } catch { + meta = await sharp(buffer, options).metadata() + } catch (e) { + if (/pixel limit/i.test(e?.message)) { + throw new IconRequestError(`The icon is too large; it should be about ${ICON_SIZE} × ${ICON_SIZE} px.`) + } throw new IconRequestError('The icon could not be read as an image. Please attach a PNG.') } if (!['png', 'jpeg', 'webp'].includes(meta.format)) { throw new IconRequestError(`The icon is a \`${meta.format}\` file. Please attach a square PNG.`) } const { width, height } = meta + if (width * height > MAX_INPUT_PIXELS) { + throw new IconRequestError(`The icon is too large (${width} × ${height} px); it should be about ${ICON_SIZE} × ${ICON_SIZE} px.`) + } if (Math.abs(width - height) / Math.max(width, height) > SQUARE_TOLERANCE) { throw new IconRequestError(`The icon must be square; this one is ${width} × ${height} px.`) } if (Math.min(width, height) < MIN_SOURCE_SIZE) { throw new IconRequestError(`The icon is too small (${width} × ${height} px); it should be about ${ICON_SIZE} × ${ICON_SIZE} px.`) } - const png = await sharp(buffer) + const png = await sharp(buffer, options) .resize(ICON_SIZE, ICON_SIZE, { fit: 'cover' }) .png({ compressionLevel: 9 }) .toBuffer() return { png, width, height } } +// Checks the bytes are a 100x100 PNG by reading its header, without decoding it +function checkPreparedPng(png) { + if (png.length > MAX_ICON_BYTES || !png.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new IconRequestError('The prepared icon is not a valid PNG.') + } + if (png.toString('latin1', 12, 16) !== 'IHDR' || png.readUInt32BE(16) !== ICON_SIZE || png.readUInt32BE(20) !== ICON_SIZE) { + throw new IconRequestError(`The prepared icon is not ${ICON_SIZE} × ${ICON_SIZE} px.`) + } +} + function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')) } -async function main() { +async function prepare() { const body = process.env.ISSUE_BODY || '' const plugin = getPluginName(body) - - // The plugin must be a real package, so a typo in the name does not add an - // icon nothing will ever use - const npm = await fetch(`https://registry.npmjs.org/${encodeURIComponent(plugin)}`, { method: 'HEAD' }) - if (npm.status === 404) { - throw new IconRequestError(`\`${plugin}\` was not found on npm. Please check the "Plugin Name" field.`) - } - if (!npm.ok) { - throw new IconRequestError(`Could not check \`${plugin}\` on npm (HTTP ${npm.status}); please try again later.`) - } - + const requester = await checkRequester(plugin) const source = getIconUrl(body) const { png, width, height } = await toIcon(await download(source)) + fs.writeFileSync(process.env.ICON_FILE, png) + return { + ok: true, + plugin, + source, + width, + height, + requester, + } +} + +async function commit() { + const prepared = JSON.parse(process.env.PREPARED || '{}') + // Re-checked here: the prepare job is the one that handled untrusted input + if (!PLUGIN_NAME.test(prepared.plugin || '')) { + throw new IconRequestError('The prepared plugin name is not valid.') + } + const { plugin } = prepared + const png = fs.readFileSync(process.env.ICON_FILE) + checkPreparedPng(png) const icons = readJson('plugin-icons.json') const iconPath = icons[plugin] || `icons/${plugin.split('/').pop()}.png` @@ -178,19 +296,21 @@ async function main() { fs.writeFileSync('plugin-icons.json', `${JSON.stringify(icons, null, 2)}\n`) return { - ok: true, - plugin, + ...prepared, iconPath, updated, - source, - width, - height, } } +const steps = { prepare, commit } +const step = steps[process.argv[2]] + let result try { - result = await main() + if (!step) { + throw new Error(`Usage: node scripts/apply-icon-request.js <${Object.keys(steps).join('|')}>`) + } + result = await step() } catch (e) { if (!(e instanceof IconRequestError)) { console.error(e) From eee3ad40f8d5e851e22fd0870fad7802d341afce Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:49 -0400 Subject: [PATCH 8/9] Check icon requests before they can be approved A new Icon Request Check workflow runs the read-only prepare step when an icon request is opened, edited or reopened, on a `/check` comment, or by hand. It keeps one comment on the issue saying whether the request is ready (with the icon as it will appear) or what to fix, and sets awaiting-final-review or awaiting-changes. Requests opened without the form get the request-icon label. `icon-approved` now only applies an icon when the latest check passed, the issue has not been edited since, and the image downloaded matches the SHA-256 the check recorded. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/icon-request-check.yml | 205 +++++++++++++++++++++++ .github/workflows/icon-request.yml | 44 ++++- scripts/apply-icon-request.js | 28 +++- 3 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/icon-request-check.yml diff --git a/.github/workflows/icon-request-check.yml b/.github/workflows/icon-request-check.yml new file mode 100644 index 00000000..eb211e9f --- /dev/null +++ b/.github/workflows/icon-request-check.yml @@ -0,0 +1,205 @@ +name: Icon Request Check + +# Checks an icon request whenever it is opened, edited or reopened (or when someone +# comments `/check` on it, or a maintainer runs this by hand), and keeps one comment +# on the issue up to date saying whether it is ready to approve. Nothing is committed. +# +# The comment carries a hidden marker with the result and a SHA-256 of the image it +# checked. The Icon Request workflow only applies an icon when the latest check passed +# and the image is still the one that was checked. +on: + issues: + types: + - opened + - edited + - reopened + issue_comment: + types: + - created + workflow_dispatch: + inputs: + issue: + description: Issue number of the icon request + required: true + type: number + +permissions: {} + +jobs: + load: + # Cheap filters here; the rest is decided once the issue is loaded + if: >- + github.event_name == 'workflow_dispatch' + || (github.event_name == 'issues' + && (contains(github.event.issue.labels.*.name, 'request-icon') || startsWith(github.event.issue.title, 'Plugin Icon Request:'))) + || (github.event_name == 'issue_comment' + && github.event.issue.pull_request == null + && github.event.comment.user.type != 'Bot' + && contains(github.event.issue.labels.*.name, 'request-icon')) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: read + outputs: + run: ${{ steps.load.outputs.run }} + number: ${{ steps.load.outputs.number }} + body: ${{ steps.load.outputs.body }} + author: ${{ steps.load.outputs.author }} + author_is_maintainer: ${{ steps.load.outputs.author_is_maintainer }} + steps: + - name: Load the issue + id: load + uses: actions/github-script@v9 + env: + DISPATCH_ISSUE: ${{ inputs.issue }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + + // A comment only counts when /check is on a line of its own + if (context.eventName === 'issue_comment' + && !context.payload.comment.body.split('\n').some(line => line.trim() === '/check')) { + core.setOutput('run', 'false'); + return; + } + + // Always read the issue fresh, so the check sees its current body + const number = Number(process.env.DISPATCH_ISSUE || context.issue.number); + const { data: issue } = await github.rest.issues.get({ ...repo, issue_number: number }); + const isIconRequest = issue.labels.some(l => l.name === 'request-icon') + || issue.title.startsWith('Plugin Icon Request:'); + if (issue.state !== 'open' || issue.pull_request || !isIconRequest) { + core.info(`#${number} is not an open icon request; nothing to check.`); + core.setOutput('run', 'false'); + return; + } + + let permission = 'none'; + try { + ({ data: { permission } } = await github.rest.repos.getCollaboratorPermissionLevel({ ...repo, username: issue.user.login })); + } catch {} + + core.setOutput('run', 'true'); + core.setOutput('number', String(number)); + core.setOutput('body', issue.body || ''); + core.setOutput('author', issue.user.login); + core.setOutput('author_is_maintainer', String(['admin', 'maintain', 'write'].includes(permission))); + + check: + needs: load + if: needs.load.outputs.run == 'true' + # A newer edit makes an older check pointless. Set here rather than on the whole + # workflow, so an ordinary comment (which loads but skips this job) cancels nothing. + concurrency: + group: icon-request-check-${{ needs.load.outputs.number }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + result: ${{ steps.prepare.outputs.result }} + steps: + - uses: actions/checkout@v7 + with: + ref: latest + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: v24 + + - name: Install sharp + run: npm install --no-save sharp@0.35.4 + + - name: Check the request + id: prepare + env: + ISSUE_BODY: ${{ needs.load.outputs.body }} + ISSUE_AUTHOR: ${{ needs.load.outputs.author }} + AUTHOR_IS_MAINTAINER: ${{ needs.load.outputs.author_is_maintainer }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ICON_FILE: ${{ runner.temp }}/icon.png + RESULT_FILE: ${{ runner.temp }}/icon-result.json + run: | + node scripts/apply-icon-request.js prepare + echo "result=$(jq -c . "$RESULT_FILE")" >> "$GITHUB_OUTPUT" + + report: + needs: + - load + - check + if: always() && needs.load.outputs.run == 'true' && needs.check.result != 'cancelled' + concurrency: + group: icon-request-check-report-${{ needs.load.outputs.number }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Update the check comment and labels + uses: actions/github-script@v9 + env: + ISSUE_NUMBER: ${{ needs.load.outputs.number }} + RESULT: ${{ needs.check.outputs.result }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const MARKER = 'icon-request-check'; + const issue = { owner: context.repo.owner, repo: context.repo.repo, issue_number: Number(process.env.ISSUE_NUMBER) }; + const repoUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}`; + const run = `${repoUrl}/actions/runs/${context.runId}`; + + let result; + try { + result = JSON.parse(process.env.RESULT); + } catch { + result = { ok: false, error: 'The check failed before it produced a result; a maintainer will look at the workflow log.' }; + } + + // Hidden data the Icon Request workflow reads when the icon is approved + const data = { ok: result.ok === true, sha256: result.sourceSha256 || null, plugin: result.plugin || null }; + let body = `\n`; + if (result.ok) { + const size = result.width === 100 && result.height === 100 + ? '100 × 100 px' + : `${result.width} × ${result.height} px, will be resized to 100 × 100 px`; + body += '### ✅ This icon request is ready for review\n\n' + + `- **Plugin:** \`${result.plugin}\`\n` + + `- **Requested by:** ${result.requester}\n` + + `- **Icon:** ${size}\n` + + (result.replaces + ? `- **Replaces the current icon:** current icon\n` + : '') + + `\nrequested icon\n\n` + + 'A maintainer will review the icon. If you change it, this check runs again.'; + } else { + body += '### ❌ This icon request needs changes\n\n' + + `> ${result.error}\n\n` + + 'Please edit the issue to fix this; the check runs again when you do. You can also comment `/check` to re-run it.'; + } + body += `\n\nChecked ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC · [workflow run](${run})`; + + // Keep one check comment per issue, updated in place + const comments = await github.paginate(github.rest.issues.listComments, issue); + const existing = comments.find(c => c.user.login === 'github-actions[bot]' && c.body.includes(`/); + if (!data) { + return refuse('This request has not been checked yet. Comment `/check` to run the check, then approve it once it passes.'); + } + const check = JSON.parse(data[1]); + if (!check.ok || !check.sha256) { + return refuse('The latest check of this request found a problem (see the check comment), so the icon was not applied.'); + } + const { repository } = await github.graphql( + 'query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { issue(number: $number) { lastEditedAt } } }', + { owner: repo.owner, repo: repo.repo, number: context.issue.number }, + ); + const lastEditedAt = repository.issue.lastEditedAt; + if (lastEditedAt && new Date(lastEditedAt) > new Date(checkComment.updated_at)) { + return refuse('The request was edited after its last check. Wait for the check comment to update, review it, then approve again.'); + } + core.setOutput('ready', 'true'); + core.setOutput('expected_sha256', check.sha256); + prepare: needs: authorize + if: needs.authorize.outputs.ready == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -94,6 +130,7 @@ jobs: ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }} AUTHOR_IS_MAINTAINER: ${{ needs.authorize.outputs.author_is_maintainer }} + EXPECTED_SHA256: ${{ needs.authorize.outputs.expected_sha256 }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ICON_FILE: ${{ runner.temp }}/icon.png RESULT_FILE: ${{ runner.temp }}/icon-result.json @@ -185,6 +222,7 @@ jobs: - name: Report on the issue uses: actions/github-script@v9 env: + REFUSAL: ${{ needs.authorize.outputs.refusal }} PREPARE_RESULT: ${{ needs.prepare.outputs.result }} APPLY_RESULT: ${{ needs.apply.outputs.result }} with: @@ -195,7 +233,9 @@ jobs: let result; try { - result = JSON.parse(process.env.APPLY_RESULT || process.env.PREPARE_RESULT); + result = process.env.REFUSAL + ? { ok: false, error: process.env.REFUSAL } + : JSON.parse(process.env.APPLY_RESULT || process.env.PREPARE_RESULT); } catch { result = { ok: false, error: 'The icon workflow failed before it produced a result.' }; } diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index f38429d3..51fc13e8 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -5,10 +5,14 @@ * * prepare Reads the request, checks the plugin exists and that the requester is * its developer, downloads the image and converts it to a 100x100 PNG. - * Runs without write access. + * Runs without write access. The Icon Request Check workflow runs this on + * its own to tell the requester whether the request is ready. * Input: ISSUE_BODY (untrusted), ISSUE_AUTHOR, AUTHOR_IS_MAINTAINER, - * GITHUB_TOKEN (read-only), ICON_FILE (where to write the PNG) - * Output: { ok, plugin, source, width, height, requester } or { ok: false, error } + * GITHUB_TOKEN (read-only), ICON_FILE (where to write the PNG), + * EXPECTED_SHA256 (optional: refuse unless the downloaded image + * is the one the check approved) + * Output: { ok, plugin, source, sourceSha256, width, height, requester, + * replaces } or { ok: false, error } * * commit Re-checks the prepared PNG without decoding it, then writes it to icons/ * and adds it to plugin-icons.json. Runs in the job that pushes. @@ -23,6 +27,7 @@ */ import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' import fs from 'node:fs' import process from 'node:process' @@ -73,7 +78,9 @@ function getPluginName(body) { .replace(/^`+|`+$/g, '') .toLowerCase() if (!PLUGIN_NAME.test(name)) { - throw new IconRequestError(`Could not read a valid plugin name from the "Plugin Name" field (found \`${name || 'nothing'}\`).`) + // Quoted back on the issue, so keep it short and unable to break out of the code span + const found = name.replace(/`/g, '').slice(0, 80) || 'nothing' + throw new IconRequestError(`Could not read a valid plugin name from the "Plugin Name" field (found \`${found}\`).`) } return name } @@ -261,15 +268,26 @@ async function prepare() { const plugin = getPluginName(body) const requester = await checkRequester(plugin) const source = getIconUrl(body) - const { png, width, height } = await toIcon(await download(source)) + const image = await download(source) + + // Fingerprint of the original image, so an approval can insist on the image the + // check showed rather than whatever the issue holds by then + const sourceSha256 = createHash('sha256').update(image).digest('hex') + if (process.env.EXPECTED_SHA256 && process.env.EXPECTED_SHA256 !== sourceSha256) { + throw new IconRequestError('The icon has changed since the request was last checked. Please review the new icon before approving it.') + } + + const { png, width, height } = await toIcon(image) fs.writeFileSync(process.env.ICON_FILE, png) return { ok: true, plugin, source, + sourceSha256, width, height, requester, + replaces: readJson('plugin-icons.json')[plugin] || null, } } From 9747a249d430e64b2ec08411764188ede0b51929 Mon Sep 17 00:00:00 2001 From: Northern Man <19808920+NorthernMan54@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:28:24 -0400 Subject: [PATCH 9/9] Add job summaries and rename the approval workflow Each job in both workflows now writes a summary for the Actions run page: who approved and why it went ahead or was refused, the prepared image (source, size, SHA-256), the commit, and what was done on the issue. The approval workflow is renamed icon-approved.yml ("Icon Approved"). Co-Authored-By: Claude Opus 5.5 --- .../{icon-request.yml => icon-approved.yml} | 46 +++++++++++++++++-- .github/workflows/icon-request-check.yml | 29 ++++++++++-- scripts/apply-icon-request.js | 2 +- 3 files changed, 70 insertions(+), 7 deletions(-) rename .github/workflows/{icon-request.yml => icon-approved.yml} (80%) diff --git a/.github/workflows/icon-request.yml b/.github/workflows/icon-approved.yml similarity index 80% rename from .github/workflows/icon-request.yml rename to .github/workflows/icon-approved.yml index fa65931f..67a72302 100644 --- a/.github/workflows/icon-request.yml +++ b/.github/workflows/icon-approved.yml @@ -1,4 +1,4 @@ -name: Icon Request +name: Icon Approved # A maintainer approves an icon by adding the `icon-approved` label to an icon request # (an issue labelled `request-icon`). This downloads the icon, makes it a 100x100 @@ -62,21 +62,30 @@ jobs: const canWrite = permission => ['admin', 'maintain', 'write'].includes(permission); const approver = context.payload.sender.login; + const author = context.payload.issue.user.login; + const summary = core.summary.addHeading('Authorize', 3).addList([ + `Issue: #${context.issue.number} by @${author}`, + `Approved by: @${approver}`, + ]); + const approverPermission = await permissionOf(approver); if (!canWrite(approverPermission)) { await github.rest.issues.removeLabel({ ...repo, issue_number: context.issue.number, name: 'icon-approved' }); + await summary.addRaw(`❌ **Approval rejected:** @${approver} has ${approverPermission} permission; \`icon-approved\` needs write. The label was removed.`, true).write(); core.setFailed(`${approver} has ${approverPermission} permission; icon-approved needs write.`); return; } // Maintainers may request icons for plugins they do not develop - core.setOutput('author_is_maintainer', String(canWrite(await permissionOf(context.payload.issue.user.login)))); + const authorIsMaintainer = canWrite(await permissionOf(author)); + core.setOutput('author_is_maintainer', String(authorIsMaintainer)); // Only apply an icon whose latest Icon Request Check passed and is not // older than the last edit to the issue - const refuse = (reason) => { + const refuse = async (reason) => { core.setOutput('ready', 'false'); core.setOutput('refusal', reason); + await summary.addRaw(`⛔ **Not applied:** ${reason}`, true).write(); }; const issue = { ...repo, issue_number: context.issue.number }; const comments = await github.paginate(github.rest.issues.listComments, issue); @@ -99,6 +108,14 @@ jobs: } core.setOutput('ready', 'true'); core.setOutput('expected_sha256', check.sha256); + await summary + .addRaw(`✅ **Ready:** the latest check passed (${checkComment.updated_at}) and the issue has not been edited since.`, true) + .addList([ + `Approver permission: ${approverPermission}`, + `Requester is a maintainer: ${authorIsMaintainer ? 'yes' : 'no'}`, + `Expected image SHA-256: \`${check.sha256}\``, + ]) + .write(); prepare: needs: authorize @@ -141,6 +158,12 @@ jobs: if [[ "$(jq -r .ok "$RESULT_FILE")" == "true" ]]; then echo "png=$(base64 -w0 "$ICON_FILE")" >> "$GITHUB_OUTPUT" fi + jq -r --arg size "$(wc -c < "$ICON_FILE" 2>/dev/null | tr -d " ")" '"### Prepare\n" + if .ok then + "✅ **Icon prepared** (read-only job, image decoded here only)\n\n" + + "- Plugin: `\(.plugin)`\n- Requested by: \(.requester)\n- Source: \(.source)\n" + + "- Original size: \(.width) × \(.height) px\n- Image SHA-256: `\(.sourceSha256)` (matches the check)\n" + + "- Converted to: 100 × 100 PNG, \($size) bytes" + else "❌ **Stopped:** \(.error)" end' "$RESULT_FILE" >> "$GITHUB_STEP_SUMMARY" apply: needs: prepare @@ -207,6 +230,11 @@ jobs: sleep $((attempt * 5)) done echo "result=$(jq -c . "$RESULT_FILE")" >> "$GITHUB_OUTPUT" + jq -r --arg repo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" '"### Apply\n" + if (.ok | not) then "❌ **Not committed:** \(.error)" + elif .unchanged then "➖ **Nothing to commit:** the icon for `\(.plugin)` is already up to date." + else "✅ **Committed** [`\(.sha[0:7])`](\($repo)/commit/\(.sha)) to `latest`\n\n" + + "- \(if .updated then "Replaced" else "Added" end): `\(.iconPath)`\n" + + "- Also updated: `plugin-icons.json`, `assets/plugins*.json`" end' "$RESULT_FILE" >> "$GITHUB_STEP_SUMMARY" report: needs: @@ -267,3 +295,15 @@ jobs: if (result.ok) { await github.rest.issues.update({ ...issue, state: 'closed', state_reason: 'completed' }); } + + await core.summary + .addHeading('Report', 3) + .addRaw(result.ok + ? (result.unchanged ? '➖ **No change needed.**' : `✅ **Icon ${result.updated ? 'updated' : 'added'}** for \`${result.plugin}\`.`) + : `❌ **Not applied:** ${result.error}`, true) + .addList([ + `Commented on #${context.issue.number}`, + 'Removed the `icon-approved` label', + result.ok ? 'Closed the request' : 'Left the request open', + ]) + .write(); diff --git a/.github/workflows/icon-request-check.yml b/.github/workflows/icon-request-check.yml index eb211e9f..6e5ea453 100644 --- a/.github/workflows/icon-request-check.yml +++ b/.github/workflows/icon-request-check.yml @@ -5,8 +5,8 @@ name: Icon Request Check # on the issue up to date saying whether it is ready to approve. Nothing is committed. # # The comment carries a hidden marker with the result and a SHA-256 of the image it -# checked. The Icon Request workflow only applies an icon when the latest check passed -# and the image is still the one that was checked. +# checked. The Icon Approved workflow (icon-approved.yml) only applies an icon when +# the latest check passed and the image is still the one that was checked. on: issues: types: @@ -159,7 +159,7 @@ jobs: result = { ok: false, error: 'The check failed before it produced a result; a maintainer will look at the workflow log.' }; } - // Hidden data the Icon Request workflow reads when the icon is approved + // Hidden data the Icon Approved workflow reads when the icon is approved const data = { ok: result.ok === true, sha256: result.sourceSha256 || null, plugin: result.plugin || null }; let body = `\n`; if (result.ok) { @@ -203,3 +203,26 @@ jobs: for (const name of remove.filter(has)) { await github.rest.issues.removeLabel({ ...issue, name }); } + + const summary = core.summary.addHeading(`Icon request check: #${issue.issue_number}`, 3); + if (result.ok) { + summary + .addRaw('✅ **Ready for review**', true) + .addList([ + `Plugin: \`${result.plugin}\``, + `Requested by: ${result.requester}`, + `Source: ${result.source}`, + `Original size: ${result.width} × ${result.height} px`, + `Image SHA-256: \`${result.sourceSha256}\` (an approval must match this)`, + `Replaces an existing icon: ${result.replaces ? `yes (\`${result.replaces}\`)` : 'no'}`, + ]); + } else { + summary.addRaw(`❌ **Needs changes:** ${result.error}`, true); + } + await summary + .addList([ + `${existing ? 'Updated' : 'Posted'} the check comment`, + `Labels added: ${add.join(', ')}`, + `Labels removed: ${remove.filter(has).join(', ') || 'none'}`, + ]) + .write(); diff --git a/scripts/apply-icon-request.js b/scripts/apply-icon-request.js index 51fc13e8..2a80c078 100644 --- a/scripts/apply-icon-request.js +++ b/scripts/apply-icon-request.js @@ -1,5 +1,5 @@ /** - * Applies the icon from an approved icon request to the repo. Run by the Icon Request + * Applies the icon from an approved icon request to the repo. Run by the Icon Approved * workflow in two steps, in separate jobs, so the untrusted image is never decoded in * a job that can push: *