From 9ab23dfcff82e92768338462c870a699457b9b48 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 7 Mar 2026 23:42:54 +0100
Subject: [PATCH 01/29] chore: migrate to trunk-based development
---
.github/PULL_REQUEST_TEMPLATE/new_icon.md | 2 +-
.github/scripts/in_develop_labeler.py | 24 ----------
.github/workflows/build_icons.yml | 1 +
.github/workflows/check_icon_pr.yml | 19 ++++----
.github/workflows/codeql-analysis.yml | 4 +-
.github/workflows/in_develop_labeler.yml | 47 -------------------
.../in_develop_labeler_preflight.yml | 24 ----------
CONTRIBUTING.md | 38 +++++++--------
8 files changed, 31 insertions(+), 128 deletions(-)
delete mode 100644 .github/scripts/in_develop_labeler.py
delete mode 100644 .github/workflows/in_develop_labeler.yml
delete mode 100644 .github/workflows/in_develop_labeler_preflight.yml
diff --git a/.github/PULL_REQUEST_TEMPLATE/new_icon.md b/.github/PULL_REQUEST_TEMPLATE/new_icon.md
index bcc4f6a72..9e35dc41e 100644
--- a/.github/PULL_REQUEST_TEMPLATE/new_icon.md
+++ b/.github/PULL_REQUEST_TEMPLATE/new_icon.md
@@ -3,7 +3,7 @@
- [ ] PR does not match another non-stale PR currently opened
- [ ] PR name matches the format *new icon: Icon name (versions separated by comma)*. More details [here](https://github.com/devicons/devicon/wiki/Overview-on-Submitting-Icons)
-- [ ] PR's base is the `develop` branch.
+- [ ] PR's base is the `master` branch.
- [ ] Your icons are inside a folder as seen [here](https://github.com/devicons/devicon/wiki/Organizing-SVGs)
- [ ] SVG matches the standards laid out [here](https://github.com/devicons/devicon/wiki/SVG-Standards)
- [ ] A new object is added in the `devicon.json` file at the correct alphabetic position as seen [here](https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60)
diff --git a/.github/scripts/in_develop_labeler.py b/.github/scripts/in_develop_labeler.py
deleted file mode 100644
index 3d329c994..000000000
--- a/.github/scripts/in_develop_labeler.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import re
-from build_assets import arg_getters, api_handler
-
-def main():
- args = arg_getters.get_in_develop_labeler_args()
- try:
- #get pr body
- pr_body = api_handler.get_pr_by_number(args.token, args.pr_num)["body"]
-
- # find the issue closing line
- print(pr_body.split("\n"))
- issue_line = [line for line in pr_body.split("\n") if line.startswith("**This PR closes")][0]
-
- print("Issue Line is " + issue_line)
- issue_pattern = re.compile(r"\d+")
- issues_numbers = issue_pattern.findall(issue_line)
- print("Labelling issues: " + str(issues_numbers))
- api_handler.label_issues(args.token, issues_numbers, ["in-develop"])
- except IndexError: # if can't find the issue line
- print("The PR body doesn't contain `**This PR closes` keywords. Ending workflow.")
- return
-
-if __name__ == "__main__":
- main()
diff --git a/.github/workflows/build_icons.yml b/.github/workflows/build_icons.yml
index e5741d0ce..f67384286 100644
--- a/.github/workflows/build_icons.yml
+++ b/.github/workflows/build_icons.yml
@@ -84,6 +84,7 @@ jobs:
Adios,
Build Bot :sunglasses:
with:
+ base: master
branch: 'bot/build-result'
commit-message: 'Built new icons, icomoon.json and devicon.css'
title: 'bot:build new icons, icomoon.json and devicon.css'
diff --git a/.github/workflows/check_icon_pr.yml b/.github/workflows/check_icon_pr.yml
index 27e5cae47..9ce102d62 100644
--- a/.github/workflows/check_icon_pr.yml
+++ b/.github/workflows/check_icon_pr.yml
@@ -1,32 +1,33 @@
name: Check Icon PR
-on: pull_request
+on:
+ pull_request:
+ paths:
+ - 'icons/**'
+ - 'devicon.json'
jobs:
check:
name: Check the `devicon.json` and the SVGs' quality
runs-on: ubuntu-latest
- if: startsWith(github.event.pull_request.title, 'new icon') || startsWith(github.event.pull_request.title, 'update icon') # only checks icon PR
steps:
- uses: actions/checkout@v4
- - name: Check if PR is develop
- if: ${{ github.base_ref != 'develop' }}
+ - name: Check if PR targets master
+ if: ${{ github.base_ref != 'master' }}
run: |
- echo -e "The PR's base branch is \`${{ github.base_ref }}\`, but should be \`develop\`\nPlease change the PR so that it's based on, and merged into \`develop\`" > ./err_messages.txt
- echo "wrong_branch=true" >> $GITHUB_ENV
+ echo "Error: This PR targets '${{ github.base_ref }}' but should target 'master'"
+ echo "Please change the PR so that it's based on, and merged into 'master'"
+ exit 1
- uses: actions/setup-python@v5
- if: ${{ !env.wrong_branch }}
with:
python-version: 3.8
- name: Install dependencies
- if: ${{ !env.wrong_branch }}
run: |
python -m pip install --upgrade pip
pip install -r ./.github/scripts/requirements.txt
- name: Run the check_svg script
- if: ${{ !env.wrong_branch }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: python ./.github/scripts/check_icon_pr.py "$PR_TITLE" ./icons ./devicon.json
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 4dbf64031..690492c3c 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -2,9 +2,9 @@ name: "Code Scanning - Action"
on:
push:
- branches: [master, develop]
+ branches: [master]
pull_request:
- branches: [master, develop]
+ branches: [master]
schedule:
- cron: 30 1 * * 0 # Runs every Sunday 1:30 am UTC
diff --git a/.github/workflows/in_develop_labeler.yml b/.github/workflows/in_develop_labeler.yml
deleted file mode 100644
index 7cf67f1c0..000000000
--- a/.github/workflows/in_develop_labeler.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Label Issue In Develop
-on:
- workflow_run:
- workflows: ['On Develop PR Merge']
- types:
- - completed
-jobs:
- on-failure:
- runs-on: ubuntu-latest
- if: ${{ github.event.workflow_run.conclusion == 'failure' }}
- steps:
- - run: echo "First workflow was a failure"
- label_preflight:
- name: Label Issue In Develop
- runs-on: ubuntu-latest
- if: ${{ github.event.workflow_run.conclusion == 'success' }}
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup Python v3.8
- uses: actions/setup-python@v5
- with:
- python-version: 3.8
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r ./.github/scripts/requirements.txt
-
- - name: Download workflow artifact
- uses: dawidd6/action-download-artifact@v7
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- workflow: peek_icons.yml
- run_id: ${{ github.event.workflow_run.id }}
-
- - name: Read the pr_num file
- id: pr_num_reader
- uses: juliangruber/read-file-action@v1.0.0
- with:
- path: ./pr_num/pr_num.txt
-
- - name: Run in_develop_labeler.py
- env:
- TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PR_NUM: ${{ steps.pr_num_reader.outputs.content }}
- run: python ./.github/scripts/in_develop_labeler.py $TOKEN "$PR_NUM"
diff --git a/.github/workflows/in_develop_labeler_preflight.yml b/.github/workflows/in_develop_labeler_preflight.yml
deleted file mode 100644
index b2151692c..000000000
--- a/.github/workflows/in_develop_labeler_preflight.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: On Develop PR Merge
-on:
- pull_request:
- types: [closed]
- branches: [develop]
-jobs:
- save_pr_num_in_artifact:
- name: Preflight Label Issue In Develop
- runs-on: ubuntu-latest
- if: github.event.pull_request.merged == true
- steps:
- - uses: actions/checkout@v4
-
- - name: Save the PR number in an artifact
- shell: bash
- env:
- PR_NUM: ${{ github.event.number }}
- run: echo $PR_NUM > pr_num.txt
-
- - name: Upload the PR number
- uses: actions/upload-artifact@v4
- with:
- name: pr_num
- path: ./pr_num.txt
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4efc27ec7..772743dbe 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -75,7 +75,7 @@ First of all, thanks for taking the time to contribute! This project can only gr
Create the SVGs for each SVG versions that you have. Follow the convention listed.
Put the SVGs of each Icon into its own folders in /icons
Update the devicon.json to include the new Icon
- Create a separated pull request (PR) towards the develop branch for each Icon.
+ Create a separated pull request (PR) towards the master branch for each Icon.
Fill out the info as stated in the PR template.
Include the name of the Icon in the pull request title in this format: new icon: Icon name (versions)
Optional: Reference the issues regarding the new icon and label the PR `feature:icon`.
@@ -344,7 +344,7 @@ As an example, let's assume you have created the SVGs for Redhat and Amazon Web
Create a new commit to fix the SVGs.
- Open a pull request based on the `develop` branch.
+ Open a pull request based on the `master` branch.
IMPORTANT: name the pull request update icon: icon-name (versions). Basically, follow the Overview on Submitting Icon but replace the new with update in name of request with the above.
@@ -361,19 +361,19 @@ As an example, let's assume you have created the SVGs for Redhat and Amazon Web
You don't have to be in a team to contribute!
- The branches master and develop are protected branches and only members
- with corresponding permissions (see teams below) are able to push changes to them.
+ The master branch is the protected branch and only members
+ with corresponding permissions (see teams below) are able to push changes to it.
Additional branches must follow the pattern username/feature/description.
The /feature/ indicates that a change is made to existing code (regardless
- if it's a fix, refactor or actual feature). The naming convention is based on the gitflow-workflow.
+ if it's a fix, refactor or actual feature).
For organisational purposes we introduced teams with permissions and responsibilities:
- Supporter (@devicons/supporter)
-
Members of this team are responsible for reviewing pull request (auto assigned), managing issues and preparing the upcoming release.
- Supporters have Write access to the repository (allowing them to create own branches)
- and are allowed to push changes to the develop branch (pull request and status checks required).
+ Supporters have Write access to the repository (allowing them to create own branches)
+ and are allowed to push changes to the master branch (pull request and status checks required).
- Maintainer (@devicons/maintainer)
-
@@ -470,29 +470,25 @@ We are running a Discord server. You can go here to talk, discuss, and more with
Release preparation and execution
- Define the next release version number based on the conventions
- - Checkout
development as draft-release branch
- - Bump the package version using
npm version vMAJOR.MINOR.PATCH -m "bump npm version to vMAJOR.MINOR.PATCH" (see #487)
+ - Checkout
master as draft-release branch: git checkout master && git checkout -b draft-release
+ - Bump the package version using
npm version vMAJOR.MINOR.PATCH -m "bump npm version to vMAJOR.MINOR.PATCH"
- Push the branch
draft-release
- Manually trigger the workflow
build_icons.yml (which has a workflow_dispatch event trigger) and select the branch draft-release as target branch. This will build a font version of all icons using icomoon and automatically creates a pull request to merge the build result back into draft-release
- - Review and approve the auto-create pull request created by the action of the step above
- - Create a pull request towards
development. Mention the release number in the pull request title (like "Build preparation for release vMAJOR.MINOR.PATCH).
-
- -
- Add information about all new icons, fixes, features and enhancements in the description of the pull request.
-
+ - Review, approve and merge the auto-created pull request
+ - Create a pull request from
draft-release towards master. Mention the release number in the pull request title (like "Release vMAJOR.MINOR.PATCH").
+
-
- Take the PRs/commits as a guideline. It's also a good idea to mention and thank all contributions who participated in the release (take description of
#504 as an example).
+ Add information about all new icons, fixes, features and enhancements in the description of the pull request.
-
- We now have a script that will do this for us. Check the `build-bot`'s PR message in the last step. There should be a section where it displays the features that have been added to the release. You can copy the markdown there and use it for the release message.
+ We have a script that will do this for you. Check the `build-bot`'s PR message in the previous step. There should be a section where it displays the features that have been added to the release. You can copy the markdown there and use it for the release message.
- - Wait for review and approval of the pull request (you can perform a squash-merge)
- - Once merged create a pull request with BASE
master and HEAD development. Copy the description of the earlier pull request.
- - Since it was already approved in the 'development' stage a maintainer is allowed to merge it (DON'T perform a squash-merge).
+ - Since it was already reviewed, a maintainer can merge it (DON'T perform a squash-merge).
- Create a new release using the format "Release vMAJOR.MINOR.PATCH" as tag and release title. Use the earlier created description as description of the release.
- - Publishing the release will trigger the npm_publish.yml workflow which will execute a
npm publish leading to a updated npm package (vMAJOR.MINOR.PATCH).
+ - Publishing the release will trigger the npm_publish.yml workflow which will execute a
npm publish leading to an updated npm package (vMAJOR.MINOR.PATCH).
+ - If icons fail to appear on the website, clear the CDN cache.
Recommended resources and tools
From b7d4cd3bf29e8ddb1783739d2e6dbdf626f30350 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sun, 8 Mar 2026 00:49:59 +0100
Subject: [PATCH 02/29] feat: automate build, merge, and release on PR approval
---
.github/workflows/auto_build_merge.yml | 188 ++++++++++++++++++++++
.github/workflows/peek_icons.yml | 6 +-
.github/workflows/version_and_release.yml | 101 ++++++++++++
3 files changed, 293 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/auto_build_merge.yml
create mode 100644 .github/workflows/version_and_release.yml
diff --git a/.github/workflows/auto_build_merge.yml b/.github/workflows/auto_build_merge.yml
new file mode 100644
index 000000000..3788d5821
--- /dev/null
+++ b/.github/workflows/auto_build_merge.yml
@@ -0,0 +1,188 @@
+name: Auto Build and Merge
+on:
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ auto-build-merge:
+ name: Auto Build and Merge
+ runs-on: ubuntu-latest
+ if: github.event.review.state == 'approved' && github.event.pull_request.base.ref == 'master'
+
+ steps:
+ - name: Check if PR modifies icon files
+ id: check-files
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { data: files } = await github.rest.pulls.listFiles({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.payload.pull_request.number
+ });
+
+ const hasIconChanges = files.some(file =>
+ file.filename.startsWith('icons/') || file.filename === 'devicon.json'
+ );
+
+ console.log('Has icon changes:', hasIconChanges);
+ core.setOutput('has_icon_changes', hasIconChanges);
+
+ if (!hasIconChanges) {
+ console.log('PR does not modify icon files, skipping build');
+ }
+
+ return hasIconChanges;
+
+ - name: Check approval count
+ if: steps.check-files.outputs.has_icon_changes == 'true'
+ id: check-approvals
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { data: reviews } = await github.rest.pulls.listReviews({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.payload.pull_request.number
+ });
+
+ // Get unique approvers (latest review per user)
+ const reviewsByUser = {};
+ for (const review of reviews) {
+ if (!reviewsByUser[review.user.login] ||
+ new Date(review.submitted_at) > new Date(reviewsByUser[review.user.login].submitted_at)) {
+ reviewsByUser[review.user.login] = review;
+ }
+ }
+
+ const approvals = Object.values(reviewsByUser).filter(r => r.state === 'APPROVED');
+ const approvalCount = approvals.length;
+
+ console.log('Approval count:', approvalCount);
+ core.setOutput('approval_count', approvalCount);
+ core.setOutput('approvers', JSON.stringify(approvals.map(r => r.user.login)));
+
+ if (approvalCount < 1) {
+ console.log('Less than 1 approval, skipping build');
+ return false;
+ }
+
+ return true;
+
+ - name: Checkout PR branch
+ if: steps.check-approvals.outputs.approval_count >= 1
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.ref }}
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup Python 3.10
+ if: steps.check-approvals.outputs.approval_count >= 1
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+
+ - name: Setup Node.js
+ if: steps.check-approvals.outputs.approval_count >= 1
+ uses: actions/setup-node@v4
+ with:
+ node-version: '16'
+
+ - name: Install Python dependencies
+ if: steps.check-approvals.outputs.approval_count >= 1
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r ./.github/scripts/requirements.txt
+
+ - name: Install Node dependencies
+ if: steps.check-approvals.outputs.approval_count >= 1
+ run: npm install
+
+ - name: Run Icomoon build
+ if: steps.check-approvals.outputs.approval_count >= 1
+ id: build
+ continue-on-error: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python ./.github/scripts/icomoon_build.py \
+ ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
+ ./icomoon.json \
+ ./devicon.json \
+ ./icons \
+ ./ \
+ $GITHUB_TOKEN \
+ --headless
+
+ - name: Build CSS
+ if: steps.build.outcome == 'success'
+ id: build-css
+ continue-on-error: true
+ run: npm run build-css
+
+ - name: Handle build failure
+ if: steps.build.outcome == 'failure' || steps.build-css.outcome == 'failure'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const approvers = JSON.parse('${{ steps.check-approvals.outputs.approvers }}');
+ const prAuthor = context.payload.pull_request.user.login;
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+
+ const mentions = ['@' + prAuthor, ...approvers.map(a => '@' + a)].join(' ');
+
+ const commentBody = `${mentions}
+
+The Icomoon build has failed for this PR. Please review the errors and fix them.
+
+**Workflow run:** ${runUrl}
+
+The build must pass before this PR can be merged.`;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.pull_request.number,
+ body: commentBody
+ });
+
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.pull_request.number,
+ labels: ['build-failed']
+ });
+
+ core.setFailed('Build failed');
+
+ - name: Commit build artifacts
+ if: steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
+ run: |
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git add -A
+ if ! git diff --staged --quiet; then
+ git commit -m "build: generate font files and CSS
+
+π€ Generated with [Claude Code](https://claude.com/claude-code)
+
+Co-Authored-By: Claude "
+ git push
+ else
+ echo "No changes to commit"
+ fi
+
+ - name: Merge PR
+ if: steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ await github.rest.pulls.merge({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.payload.pull_request.number,
+ merge_method: 'squash'
+ });
+
+ console.log('PR merged successfully');
diff --git a/.github/workflows/peek_icons.yml b/.github/workflows/peek_icons.yml
index a337d5543..495222432 100644
--- a/.github/workflows/peek_icons.yml
+++ b/.github/workflows/peek_icons.yml
@@ -1,14 +1,16 @@
name: Peek Icons
on:
pull_request:
- types: [labeled]
+ types: [opened, synchronize, reopened]
+ paths:
+ - 'icons/**'
+ - 'devicon.json'
jobs:
peek:
# four outcomes: successful check and upload,
# unsuccessful check (fail due to user),
# fail due to system, skipped
name: Peek Icons
- if: github.event.label.name == 'bot:peek'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/version_and_release.yml b/.github/workflows/version_and_release.yml
new file mode 100644
index 000000000..299dd4d37
--- /dev/null
+++ b/.github/workflows/version_and_release.yml
@@ -0,0 +1,101 @@
+name: Version and Release
+on:
+ push:
+ branches: [master]
+ paths:
+ - 'icons/**'
+ - 'devicon.json'
+
+permissions:
+ contents: write
+
+jobs:
+ version-and-release:
+ name: Bump Version and Create Release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Skip if version bump or build commit
+ id: check-skip
+ run: |
+ MSG="${{ github.event.head_commit.message }}"
+ if [[ "$MSG" == build:* ]] || [[ "$MSG" == chore:\ bump\ version* ]]; then
+ echo "skip=true" >> $GITHUB_OUTPUT
+ else
+ echo "skip=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Checkout master
+ if: steps.check-skip.outputs.skip == 'false'
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Get merged PR number and labels
+ if: steps.check-skip.outputs.skip == 'false'
+ id: pr-info
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const msg = context.payload.head_commit.message;
+ const match = msg.match(/\(#(\d+)\)/);
+ if (!match) {
+ console.log('No PR number found in commit message, defaulting to patch');
+ core.setOutput('bump', 'patch');
+ return;
+ }
+
+ const prNumber = parseInt(match[1]);
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber
+ });
+
+ const labels = pr.labels.map(l => l.name);
+ console.log('PR labels:', labels);
+
+ let bump = 'patch';
+ if (labels.includes('release: major')) bump = 'major';
+ else if (labels.includes('release: minor')) bump = 'minor';
+
+ console.log('Version bump type:', bump);
+ core.setOutput('bump', bump);
+ core.setOutput('pr_number', prNumber);
+
+ - name: Setup Node.js
+ if: steps.check-skip.outputs.skip == 'false'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '16'
+
+ - name: Configure git
+ if: steps.check-skip.outputs.skip == 'false'
+ run: |
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+
+ - name: Bump version
+ if: steps.check-skip.outputs.skip == 'false'
+ id: bump
+ run: |
+ BUMP="${{ steps.pr-info.outputs.bump }}"
+ npm version $BUMP -m "chore: bump version to %s"
+ VERSION=$(node -p "require('./package.json').version")
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ git push --follow-tags
+
+ - name: Create GitHub release
+ if: steps.check-skip.outputs.skip == 'false'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const version = '${{ steps.bump.outputs.version }}';
+ await github.rest.repos.createRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ tag_name: `v${version}`,
+ name: `Release v${version}`,
+ generate_release_notes: true
+ });
+ console.log(`Release v${version} created`);
From 01bc49a2f3f21031d3d195514ff1f92c67f2b9a1 Mon Sep 17 00:00:00 2001
From: snailedlt
Date: Sun, 8 Mar 2026 13:03:42 +0100
Subject: [PATCH 03/29] feat: add nightly build workflow and fix peek
screenshot PR num path
---
.github/workflows/build_nightly.yml | 223 +++++++++++++++++++++
.github/workflows/post_peek_screenshot.yml | 2 +-
2 files changed, 224 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/build_nightly.yml
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
new file mode 100644
index 000000000..abb7f86c0
--- /dev/null
+++ b/.github/workflows/build_nightly.yml
@@ -0,0 +1,223 @@
+name: Nightly Build and Release
+
+on:
+ schedule:
+ - cron: '0 2 * * *' # 2 AM UTC daily
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: 'Version bump level (auto = derive from PR labels)'
+ required: false
+ default: 'auto'
+ type: choice
+ options:
+ - auto
+ - patch
+ - minor
+ - major
+
+permissions:
+ contents: write
+ issues: write
+
+jobs:
+ build-and-release:
+ name: Build, Bump Version, and Release
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout master
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Check for new icon commits since last build
+ id: check-changes
+ run: |
+ # Find the last commit whose message starts with "build:"
+ LAST_BUILD=$(git log --format="%H %s" | awk '/^[a-f0-9]+ build:/ { print $1; exit }')
+
+ if [ -z "$LAST_BUILD" ]; then
+ echo "No previous build commit found β checking all history"
+ RANGE="HEAD"
+ else
+ echo "Last build commit: $LAST_BUILD"
+ RANGE="${LAST_BUILD}..HEAD"
+ fi
+
+ # Check if any commits in range touch icons/ or devicon.json
+ CHANGES=$(git diff --name-only $RANGE -- icons/ devicon.json 2>/dev/null)
+
+ if [ -z "$CHANGES" ]; then
+ echo "No icon changes since last build β skipping"
+ echo "has_changes=false" >> $GITHUB_OUTPUT
+ else
+ echo "Icon changes found since last build"
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ echo "range=$RANGE" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Determine version bump level
+ if: steps.check-changes.outputs.has_changes == 'true'
+ id: version-bump
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const manualBump = '${{ inputs.bump }}';
+ if (manualBump && manualBump !== 'auto') {
+ console.log('Manual bump override:', manualBump);
+ core.setOutput('bump', manualBump);
+ return;
+ }
+
+ const range = '${{ steps.check-changes.outputs.range }}';
+ const { execSync } = require('child_process');
+
+ // Get commit messages in range that touch icon files
+ let log = '';
+ try {
+ log = execSync(`git log --format="%s" ${range} -- icons/ devicon.json`).toString().trim();
+ } catch (e) {
+ console.log('Could not get git log:', e.message);
+ }
+
+ if (!log) {
+ console.log('No matching commits found, defaulting to patch');
+ core.setOutput('bump', 'patch');
+ return;
+ }
+
+ // Extract PR numbers from squash commit messages like "new icon: foo (#123)"
+ const prNumbers = [...log.matchAll(/\(#(\d+)\)/g)].map(m => parseInt(m[1]));
+ console.log('PR numbers found in commits:', prNumbers);
+
+ let bump = 'patch';
+
+ for (const prNumber of prNumbers) {
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber
+ });
+
+ const labels = pr.labels.map(l => l.name);
+ console.log(`PR #${prNumber} labels:`, labels);
+
+ if (labels.includes('release: major')) {
+ bump = 'major';
+ break; // Can't go higher
+ } else if (labels.includes('release: minor') && bump !== 'major') {
+ bump = 'minor';
+ }
+ } catch (e) {
+ console.log(`Could not fetch PR #${prNumber}:`, e.message);
+ }
+ }
+
+ console.log('Final version bump:', bump);
+ core.setOutput('bump', bump);
+
+ - name: Setup Python 3.10
+ if: steps.check-changes.outputs.has_changes == 'true'
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+
+ - name: Setup Node.js
+ if: steps.check-changes.outputs.has_changes == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Python dependencies
+ if: steps.check-changes.outputs.has_changes == 'true'
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r ./.github/scripts/requirements.txt
+
+ - name: Install Node dependencies
+ if: steps.check-changes.outputs.has_changes == 'true'
+ run: npm install
+
+ - name: Run Icomoon build
+ if: steps.check-changes.outputs.has_changes == 'true'
+ id: build
+ continue-on-error: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python ./.github/scripts/icomoon_build.py \
+ ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
+ ./icomoon.json \
+ ./devicon.json \
+ ./icons \
+ ./ \
+ $GITHUB_TOKEN \
+ --headless
+
+ - name: Build CSS
+ if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success'
+ id: build-css
+ continue-on-error: true
+ run: npm run build-css
+
+ - name: Handle build failure
+ if: steps.check-changes.outputs.has_changes == 'true' && (steps.build.outcome == 'failure' || steps.build-css.outcome == 'failure')
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const today = new Date().toISOString().split('T')[0];
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+
+ await github.rest.issues.create({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ title: `Nightly build failed - ${today}`,
+ body: `The nightly Icomoon build failed.\n\n**Workflow run:** ${runUrl}\n\nPlease investigate and re-run manually once fixed using the \`workflow_dispatch\` trigger on \`build_nightly.yml\`.`,
+ labels: ['build-failed']
+ });
+
+ core.setFailed('Nightly build failed β issue created');
+
+ - name: Commit build artifacts
+ if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
+ run: |
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git add -A
+ if ! git diff --staged --quiet; then
+ git commit -m "build: generate font files and CSS"
+ git push
+ else
+ echo "No build artifacts changed"
+ fi
+
+ - name: Bump version
+ if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
+ id: bump
+ run: |
+ BUMP="${{ steps.version-bump.outputs.bump }}"
+ npm version $BUMP -m "chore: bump version to %s"
+ VERSION=$(node -p "require('./package.json').version")
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ git push --follow-tags
+
+ - name: Create GitHub release
+ if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const version = '${{ steps.bump.outputs.version }}';
+ const bump = '${{ steps.version-bump.outputs.bump }}';
+
+ await github.rest.repos.createRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ tag_name: `v${version}`,
+ name: `Release v${version}`,
+ generate_release_notes: true
+ });
+
+ console.log(`Release v${version} created (${bump} bump)`);
diff --git a/.github/workflows/post_peek_screenshot.yml b/.github/workflows/post_peek_screenshot.yml
index f52cf9ffa..44d3c2d65 100644
--- a/.github/workflows/post_peek_screenshot.yml
+++ b/.github/workflows/post_peek_screenshot.yml
@@ -30,7 +30,7 @@ jobs:
id: pr_num_reader
uses: juliangruber/read-file-action@v1.1.6
with:
- path: ./pr_num/pr_num.txt
+ path: ./peek-pr-num/pr_num.txt
- name: Read the err message file
if: success()
From 32c4f7f9359e425dd6357310f6af3ab09188ffd3 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 04:07:47 +0200
Subject: [PATCH 04/29] chore: finalize trunk-based workflow refactor
- auto_build_merge.yml: stripped to validate + squash merge only (no build)
- build_icons.yml: emergency fallback, commits directly to master (no PR)
- version_and_release.yml: deprecated, workflow_dispatch only
- CLAUDE.md: added project guide for Claude Code
Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.github/workflows/auto_build_merge.yml | 185 ++++++----------------
.github/workflows/build_icons.yml | 133 ++++++++--------
.github/workflows/version_and_release.yml | 80 +++-------
CLAUDE.md | 111 +++++++++++++
4 files changed, 248 insertions(+), 261 deletions(-)
create mode 100644 CLAUDE.md
diff --git a/.github/workflows/auto_build_merge.yml b/.github/workflows/auto_build_merge.yml
index 3788d5821..16a7ebb78 100644
--- a/.github/workflows/auto_build_merge.yml
+++ b/.github/workflows/auto_build_merge.yml
@@ -1,13 +1,22 @@
name: Auto Build and Merge
+
on:
pull_request_review:
types: [submitted]
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+
jobs:
- auto-build-merge:
- name: Auto Build and Merge
+ auto-merge:
+ name: Auto Merge Icon PR
runs-on: ubuntu-latest
- if: github.event.review.state == 'approved' && github.event.pull_request.base.ref == 'master'
+ # Only act on approvals targeting master from the base repo (not forks)
+ if: >
+ github.event.review.state == 'approved' &&
+ github.event.pull_request.base.ref == 'master'
steps:
- name: Check if PR modifies icon files
@@ -26,14 +35,12 @@ jobs:
);
console.log('Has icon changes:', hasIconChanges);
- core.setOutput('has_icon_changes', hasIconChanges);
+ core.setOutput('has_icon_changes', String(hasIconChanges));
if (!hasIconChanges) {
- console.log('PR does not modify icon files, skipping build');
+ console.log('PR does not modify icon files β skipping auto-merge');
}
- return hasIconChanges;
-
- name: Check approval count
if: steps.check-files.outputs.has_icon_changes == 'true'
id: check-approvals
@@ -46,143 +53,49 @@ jobs:
pull_number: context.payload.pull_request.number
});
- // Get unique approvers (latest review per user)
+ // Latest review per user wins
const reviewsByUser = {};
for (const review of reviews) {
- if (!reviewsByUser[review.user.login] ||
- new Date(review.submitted_at) > new Date(reviewsByUser[review.user.login].submitted_at)) {
+ if (
+ !reviewsByUser[review.user.login] ||
+ new Date(review.submitted_at) > new Date(reviewsByUser[review.user.login].submitted_at)
+ ) {
reviewsByUser[review.user.login] = review;
}
}
const approvals = Object.values(reviewsByUser).filter(r => r.state === 'APPROVED');
- const approvalCount = approvals.length;
-
- console.log('Approval count:', approvalCount);
- core.setOutput('approval_count', approvalCount);
- core.setOutput('approvers', JSON.stringify(approvals.map(r => r.user.login)));
-
- if (approvalCount < 1) {
- console.log('Less than 1 approval, skipping build');
- return false;
- }
-
- return true;
-
- - name: Checkout PR branch
- if: steps.check-approvals.outputs.approval_count >= 1
- uses: actions/checkout@v4
- with:
- ref: ${{ github.event.pull_request.head.ref }}
- repository: ${{ github.event.pull_request.head.repo.full_name }}
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Setup Python 3.10
- if: steps.check-approvals.outputs.approval_count >= 1
- uses: actions/setup-python@v5
- with:
- python-version: '3.10'
+ console.log('Approval count:', approvals.length);
+ core.setOutput('approval_count', String(approvals.length));
- - name: Setup Node.js
- if: steps.check-approvals.outputs.approval_count >= 1
- uses: actions/setup-node@v4
- with:
- node-version: '16'
-
- - name: Install Python dependencies
- if: steps.check-approvals.outputs.approval_count >= 1
- run: |
- python -m pip install --upgrade pip
- pip install -r ./.github/scripts/requirements.txt
-
- - name: Install Node dependencies
- if: steps.check-approvals.outputs.approval_count >= 1
- run: npm install
-
- - name: Run Icomoon build
- if: steps.check-approvals.outputs.approval_count >= 1
- id: build
- continue-on-error: true
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- python ./.github/scripts/icomoon_build.py \
- ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
- ./icomoon.json \
- ./devicon.json \
- ./icons \
- ./ \
- $GITHUB_TOKEN \
- --headless
-
- - name: Build CSS
- if: steps.build.outcome == 'success'
- id: build-css
- continue-on-error: true
- run: npm run build-css
-
- - name: Handle build failure
- if: steps.build.outcome == 'failure' || steps.build-css.outcome == 'failure'
+ - name: Squash merge PR
+ if: >
+ steps.check-files.outputs.has_icon_changes == 'true' &&
+ steps.check-approvals.outputs.approval_count >= 1
uses: actions/github-script@v7
with:
script: |
- const approvers = JSON.parse('${{ steps.check-approvals.outputs.approvers }}');
- const prAuthor = context.payload.pull_request.user.login;
- const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
-
- const mentions = ['@' + prAuthor, ...approvers.map(a => '@' + a)].join(' ');
-
- const commentBody = `${mentions}
-
-The Icomoon build has failed for this PR. Please review the errors and fix them.
-
-**Workflow run:** ${runUrl}
-
-The build must pass before this PR can be merged.`;
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
- body: commentBody
- });
-
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
- labels: ['build-failed']
- });
-
- core.setFailed('Build failed');
-
- - name: Commit build artifacts
- if: steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
- run: |
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
- git add -A
- if ! git diff --staged --quiet; then
- git commit -m "build: generate font files and CSS
-
-π€ Generated with [Claude Code](https://claude.com/claude-code)
-
-Co-Authored-By: Claude "
- git push
- else
- echo "No changes to commit"
- fi
-
- - name: Merge PR
- if: steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
- uses: actions/github-script@v7
- with:
- script: |
- await github.rest.pulls.merge({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.payload.pull_request.number,
- merge_method: 'squash'
- });
-
- console.log('PR merged successfully');
+ const pr = context.payload.pull_request;
+
+ try {
+ await github.rest.pulls.merge({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: pr.number,
+ merge_method: 'squash',
+ commit_title: `${pr.title} (#${pr.number})`,
+ commit_message: pr.body || ''
+ });
+ console.log(`PR #${pr.number} squash-merged successfully`);
+ } catch (err) {
+ console.error('Merge failed:', err.message);
+ // Comment on PR to alert author and approver
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ body: `@${pr.user.login} Auto-merge failed. Please check the [workflow run](${runUrl}) for details.`
+ });
+ core.setFailed('Merge failed: ' + err.message);
+ }
diff --git a/.github/workflows/build_icons.yml b/.github/workflows/build_icons.yml
index f67384286..773d5afbb 100644
--- a/.github/workflows/build_icons.yml
+++ b/.github/workflows/build_icons.yml
@@ -1,98 +1,95 @@
-name: Build Icons
-on: workflow_dispatch
+name: Build Icons (Emergency Fallback)
+
+# Manual emergency re-run for when the nightly build fails or needs to be
+# forced outside the regular schedule. Commits font files directly to master
+# with a "build:" prefix so build_nightly.yml doesn't double-process.
+#
+# For day-to-day releases, use build_nightly.yml instead.
+
+on:
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual build (for audit trail)'
+ required: false
+ default: 'Manual emergency build'
+
+permissions:
+ contents: write
+
jobs:
build:
- name: Get Fonts From Icomoon
+ name: Emergency Build β Fonts + CSS
runs-on: ubuntu-latest
+
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
+ - name: Checkout master
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup Python 3.10
+ uses: actions/setup-python@v5
with:
python-version: '3.10'
- - name: Install dependencies (python, pip, npm)
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ./.github/scripts/requirements.txt
npm install
- - name: Executing build and create fonts via icomoon
- shell: bash
+ - name: Run Icomoon build
+ id: build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: >
- python ./.github/scripts/icomoon_build.py
- ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver ./icomoon.json
- ./devicon.json ./icons ./ $GITHUB_TOKEN --headless
+ run: |
+ python ./.github/scripts/icomoon_build.py \
+ ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
+ ./icomoon.json \
+ ./devicon.json \
+ ./icons \
+ ./ \
+ $GITHUB_TOKEN \
+ --headless
- - name: Upload geckodriver.log for debugging purposes
+ - name: Build devicon.min.css
+ run: npm run build-css
+
+ - name: Upload geckodriver.log on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: geckodriver-log
path: ./geckodriver.log
- - name: Upload log file for debugging purposes
+ - name: Upload build log
uses: actions/upload-artifact@v4
if: always()
with:
name: logfile
path: ./log.txt
- - name: Build devicon.min.css
- if: success()
- run: npm run build-css
-
- # - name: Upload screenshot of the newly made icons
- # id: imgur_step
- # uses: devicons/public-upload-to-imgur@v2.2.2
- # if: success()
- # with:
- # # will have "new_icons.png" and "new_svgs.png"
- # # in that order (cause sorted alphabetically)
- # path: ./screenshots/*.png
- # client_id: ${{secrets.IMGUR_CLIENT_ID}}
-
- - name: Get the release message from file
- id: release_message_step
- uses: juliangruber/read-file-action@v1.0.0
- with:
- # taken from icomoon_build.py's get_release_message()
- path: ./release_message.txt
-
- - name: Create Pull Request
- if: success()
- uses: peter-evans/create-pull-request@v7
- env:
- MESSAGE: |
- Hello,
-
- I'm Devicon's Build Bot and I just built some new font files and devicon.min.css file.
-
- The devicon.min.css file contains:
- -The icon content
- -The aliases
- -The colored classes
+ - name: Commit build artifacts directly to master
+ run: |
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git add -A
+ if ! git diff --staged --quiet; then
+ git commit -m "build: emergency font rebuild
- I also compiled a list of new features and icons that were added since last release.
- ```
- {0}
- ```
+Reason: ${{ inputs.reason }}
- More information can be found in the GitHub Action logs for this workflow.
+Generated with [Claude Code](https://claude.com/claude-code)
- Adios,
- Build Bot :sunglasses:
- with:
- base: master
- branch: 'bot/build-result'
- commit-message: 'Built new icons, icomoon.json and devicon.css'
- title: 'bot:build new icons, icomoon.json and devicon.css'
- body: >
- ${{
- format(
- env.MESSAGE,
- steps.release_message_step.outputs.content
- )
- }}
- delete-branch: true
+Co-Authored-By: Claude "
+ git push
+ else
+ echo "No changes to commit"
+ fi
diff --git a/.github/workflows/version_and_release.yml b/.github/workflows/version_and_release.yml
index 299dd4d37..6b5a9f245 100644
--- a/.github/workflows/version_and_release.yml
+++ b/.github/workflows/version_and_release.yml
@@ -1,96 +1,62 @@
-name: Version and Release
+name: Version and Release (DEPRECATED)
+
+# DEPRECATED β versioning and releases are now handled by build_nightly.yml.
+# This workflow is kept as workflow_dispatch only for manual overrides or
+# emergency use. Do not re-enable the push trigger.
+
on:
- push:
- branches: [master]
- paths:
- - 'icons/**'
- - 'devicon.json'
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: 'Version bump level'
+ required: true
+ default: 'patch'
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
permissions:
contents: write
jobs:
version-and-release:
- name: Bump Version and Create Release
+ name: Manual Version Bump and Release
runs-on: ubuntu-latest
- steps:
- - name: Skip if version bump or build commit
- id: check-skip
- run: |
- MSG="${{ github.event.head_commit.message }}"
- if [[ "$MSG" == build:* ]] || [[ "$MSG" == chore:\ bump\ version* ]]; then
- echo "skip=true" >> $GITHUB_OUTPUT
- else
- echo "skip=false" >> $GITHUB_OUTPUT
- fi
+ steps:
- name: Checkout master
- if: steps.check-skip.outputs.skip == 'false'
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- - name: Get merged PR number and labels
- if: steps.check-skip.outputs.skip == 'false'
- id: pr-info
- uses: actions/github-script@v7
- with:
- script: |
- const msg = context.payload.head_commit.message;
- const match = msg.match(/\(#(\d+)\)/);
- if (!match) {
- console.log('No PR number found in commit message, defaulting to patch');
- core.setOutput('bump', 'patch');
- return;
- }
-
- const prNumber = parseInt(match[1]);
- const { data: pr } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber
- });
-
- const labels = pr.labels.map(l => l.name);
- console.log('PR labels:', labels);
-
- let bump = 'patch';
- if (labels.includes('release: major')) bump = 'major';
- else if (labels.includes('release: minor')) bump = 'minor';
-
- console.log('Version bump type:', bump);
- core.setOutput('bump', bump);
- core.setOutput('pr_number', prNumber);
-
- name: Setup Node.js
- if: steps.check-skip.outputs.skip == 'false'
uses: actions/setup-node@v4
with:
- node-version: '16'
+ node-version: '20'
- name: Configure git
- if: steps.check-skip.outputs.skip == 'false'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
- name: Bump version
- if: steps.check-skip.outputs.skip == 'false'
id: bump
run: |
- BUMP="${{ steps.pr-info.outputs.bump }}"
+ BUMP="${{ inputs.bump }}"
npm version $BUMP -m "chore: bump version to %s"
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
git push --follow-tags
- name: Create GitHub release
- if: steps.check-skip.outputs.skip == 'false'
uses: actions/github-script@v7
with:
script: |
const version = '${{ steps.bump.outputs.version }}';
+ const bump = '${{ inputs.bump }}';
await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -98,4 +64,4 @@ jobs:
name: `Release v${version}`,
generate_release_notes: true
});
- console.log(`Release v${version} created`);
+ console.log(`Release v${version} created (manual ${bump} bump)`);
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..d30d199df
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,111 @@
+# CLAUDE.md β Devicon Project Guide
+
+This file helps Claude Code understand the structure, conventions, and workflow of the Devicon project.
+
+## Project Overview
+
+Devicon is a collection of icons for programming languages, frameworks, and developer tools. Icons are distributed as a web font (icomoon-generated) and as SVG files.
+
+- **Repo:** https://github.com/devicons/devicon (upstream) / JΓΈrgen's fork
+- **Wiki:** `D:\code\personal\devicon.wiki` (cloned locally)
+- **Version:** See `package.json`
+
+## Directory Structure
+
+```
+devicon/
+βββ icons/ # SVG source files, one folder per icon (e.g. icons/python/)
+βββ fonts/ # Generated font files (eot, svg, ttf, woff) β do not edit manually
+βββ .github/
+β βββ workflows/ # GitHub Actions workflows
+β βββ scripts/ # Python/JS automation scripts (icomoon_build.py etc.)
+β βββ PULL_REQUEST_TEMPLATE/
+βββ devicon.json # Master list of all icons + metadata
+βββ devicon-schema.json # JSON schema for devicon.json validation
+βββ icomoon.json # Icomoon project config β updated by build scripts
+βββ devicon.min.css # Generated CSS β do not edit manually
+βββ devicon-base.css # CSS template
+βββ gulpfile.js # Gulp tasks (CSS build, SVG optimization)
+βββ package.json
+```
+
+## Trunk-Based Development (current branch model)
+
+- **Single branch:** `master` only. No `develop` branch.
+- Contributors open PRs against `master`.
+- Maintainers approve β auto-merge workflow fires.
+- Font files are built nightly, not per PR.
+
+## Workflow Architecture
+
+### PR Flow
+1. Contributor opens PR β `check_icon_pr.yml` validates SVGs
+2. `peek_icons.yml` runs Icomoon peek β `post_peek_screenshot.yml` posts screenshots as PR comment
+3. Maintainer approves β `auto_build_merge.yml` squash-merges to master (**no build step**)
+
+### Nightly Build (`build_nightly.yml`)
+- Runs daily at 2 AM UTC
+- Detects icon changes since last `build:` commit
+- Runs Icomoon build β commits font files with `build:` prefix
+- Determines semver bump from merged PR labels β bumps version β creates release
+- On failure: creates a GitHub issue
+
+### Version Bump Labels
+| PR Label | Bump |
+|------------------|---------|
+| `release: major` | major |
+| `release: minor` | minor |
+| (none) | patch |
+
+### Emergency Build
+Use `build_icons.yml` (workflow_dispatch) for manual re-runs. It commits directly to master β no PR created.
+
+## Common Tasks
+
+### Run the build locally (Windows)
+```powershell
+npm run build-icons
+```
+
+### Build CSS only
+```powershell
+npm run build-css
+```
+
+### Run local dev server
+```powershell
+npm run dev
+# Opens http://localhost:8000/docs
+```
+
+### Add a new icon
+1. Create folder `icons//` with SVG variants
+2. Add entry to `devicon.json` in alphabetical order (validate against `devicon-schema.json`)
+3. Open PR against `master` with title format: `new icon: ()`
+
+## devicon.json Format
+Each entry looks like:
+```json
+{
+ "name": "python",
+ "tags": ["scripting", "data science", ...],
+ "versions": {
+ "svg": ["original", "original-wordmark", "plain", "plain-wordmark"],
+ "font": ["original", "plain", ...]
+ },
+ "color": "#3572A5",
+ "aliases": [...]
+}
+```
+
+## Important Notes
+- Font files (`fonts/`, `devicon.eot`, `devicon.ttf`, etc.) are **generated artifacts** β don't edit them directly.
+- `icomoon.json` is updated by the build script β don't edit manually.
+- `devicon.min.css` is generated by `npm run build-css` β don't edit manually.
+- The `build:` commit prefix is used by the nightly build to detect the last successful build.
+- `version_and_release.yml` is deprecated β versioning is handled by `build_nightly.yml`.
+
+## Deferred Issues (to fix later)
+- `codeql-analysis.yml`: upgrade `codeql-action@v2` β `@v3`
+- `npm_publish.yml`: comment says "Node.js v16" but uses v24 β fix comment
+- `PULL_REQUEST_TEMPLATE/new_icon.md`: link still points to `devicon/blob/develop/CONTRIBUTING.md` β update to `master`
From 2716e1184c29a551f5734c455e4383a5d05e7257 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 04:20:53 +0200
Subject: [PATCH 05/29] fix: correct pr_num artifact path in
post_peek_screenshot workflow
Artifact 'pr_num' downloads to ./pr_num/pr_num.txt, not ./peek-pr-num/pr_num.txt.
This caused step 5 to fail, skipping all comment steps.
Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.github/workflows/post_peek_screenshot.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/post_peek_screenshot.yml b/.github/workflows/post_peek_screenshot.yml
index 44d3c2d65..f52cf9ffa 100644
--- a/.github/workflows/post_peek_screenshot.yml
+++ b/.github/workflows/post_peek_screenshot.yml
@@ -30,7 +30,7 @@ jobs:
id: pr_num_reader
uses: juliangruber/read-file-action@v1.1.6
with:
- path: ./peek-pr-num/pr_num.txt
+ path: ./pr_num/pr_num.txt
- name: Read the err message file
if: success()
From c868c4e65178d85118cf7a559a9de10de1b07790 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 04:31:37 +0200
Subject: [PATCH 06/29] refactor: replace Imgur with self-hosted screenshots
branch
- Remove dependency on IMGUR_CLIENT_ID secret
- Upload screenshots to 'screenshots' branch under pr-{num}/ subdirectory
- Serve images via raw.githubusercontent.com (public, no auth needed)
- Rewrite comment steps using github-script (removes jungwinter/comment
and juliangruber/read-file-action dependencies)
- Fix pr_num reading using bash instead of read-file-action
- Fix failure comment using github-script instead of literal env.MESSAGE
Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.github/workflows/post_peek_screenshot.yml | 351 +++++++++++++--------
1 file changed, 219 insertions(+), 132 deletions(-)
diff --git a/.github/workflows/post_peek_screenshot.yml b/.github/workflows/post_peek_screenshot.yml
index f52cf9ffa..0ce65677a 100644
--- a/.github/workflows/post_peek_screenshot.yml
+++ b/.github/workflows/post_peek_screenshot.yml
@@ -4,22 +4,25 @@ on:
workflows: ['Peek Icons']
types:
- completed
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
jobs:
post_screenshots_in_comment:
name: Post the screenshot
runs-on: ubuntu-latest
if: github.event.action == 'completed' && github.event.workflow_run.conclusion != 'skipped'
env:
- # three possible values: 'skipped', 'success', 'failure'
- # have to print github.event to console to see these values
- # note: can't use this env variable up in the if statement above for some reason.
- # I don't think it's an ordering issue cause it seems 'if' is auto evaluate first
PEEK_STATUS: ${{ github.event.workflow_run.conclusion }}
+
steps:
- name: Check state of last run
- run: echo $PEEK_STATUS
+ run: echo "Peek status $PEEK_STATUS"
- - name: Download workflow artifact
+ - name: Download workflow artifacts
uses: dawidd6/action-download-artifact@v7
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -28,156 +31,240 @@ jobs:
- name: Read the pr_num file
id: pr_num_reader
- uses: juliangruber/read-file-action@v1.1.6
- with:
- path: ./pr_num/pr_num.txt
+ run: |
+ PR_NUM=$(cat ./pr_num/pr_num.txt | tr -d '[:space:]')
+ echo "pr_num=$PR_NUM" >> $GITHUB_OUTPUT
- name: Read the err message file
- if: success()
id: err_message_reader
- uses: juliangruber/read-file-action@v1.1.6
- with:
- path: ./err_messages/err_messages.txt
+ run: |
+ if [ -f ./err_messages/err_messages.txt ]; then
+ ERR=$(cat ./err_messages/err_messages.txt)
+ else
+ ERR=""
+ fi
+ echo "err_message<> $GITHUB_OUTPUT
+ echo "$ERR" >> $GITHUB_OUTPUT
+ echo "EOF" >> $GITHUB_OUTPUT
- # using this trigger allowed us to access secrets
- - name: Upload screenshot of the SVGs gotten from the artifacts
- id: svgs_overview_img_step
- if: env.PEEK_STATUS == 'success' && success()
- uses: devicons/public-upload-to-imgur@v2.2.2
+ - name: Upload screenshots to screenshots branch
+ if: env.PEEK_STATUS == 'success'
+ id: upload_screenshots
+ uses: actions/github-script@v7
with:
- path: ./screenshots/new_svgs.png
- client_id: ${{secrets.IMGUR_CLIENT_ID}}
+ script: |
+ const fs = require('fs');
+ const path = require('path');
+ const prNum = '${{ steps.pr_num_reader.outputs.pr_num }}';
+ const repo = context.repo;
+ const branch = 'screenshots';
+ const screenshotsDir = './screenshots';
- - name: Upload zoomed in screenshot of the SVGs gotten from the artifacts
- id: svgs_detailed_img_step
- uses: devicons/public-upload-to-imgur@v2.2.2
- if: env.PEEK_STATUS == 'success' && success()
- with:
- path: ./screenshots/new_svg_*.png
- client_id: ${{secrets.IMGUR_CLIENT_ID}}
+ if (!fs.existsSync(screenshotsDir)) {
+ core.setFailed('Screenshots directory not found');
+ return;
+ }
- - name: Upload screenshot of the newly made icons gotten from the artifacts
- id: icons_overview_img_step
- if: env.PEEK_STATUS == 'success' && success()
- uses: devicons/public-upload-to-imgur@v2.2.2
- with:
- path: ./screenshots/new_icons.png
- client_id: ${{secrets.IMGUR_CLIENT_ID}}
+ const files = fs.readdirSync(screenshotsDir).filter(f => f.endsWith('.png'));
+ if (files.length === 0) {
+ core.setFailed('No screenshots found');
+ return;
+ }
- - name: Upload zoomed in screenshot of the newly made icons gotten from the artifacts
- id: icons_detailed_img_step
- uses: devicons/public-upload-to-imgur@v2.2.2
- if: env.PEEK_STATUS == 'success' && success()
- with:
- path: ./screenshots/new_icon_*.png
- client_id: ${{secrets.IMGUR_CLIENT_ID}}
+ // Get or create screenshots branch
+ let branchSha;
+ try {
+ const { data: ref } = await github.rest.git.getRef({
+ ...repo,
+ ref: `heads/${branch}`
+ });
+ branchSha = ref.object.sha;
+ } catch (e) {
+ const { data: master } = await github.rest.git.getRef({
+ ...repo,
+ ref: 'heads/master'
+ });
+ const { data: newBranch } = await github.rest.git.createRef({
+ ...repo,
+ ref: `refs/heads/${branch}`,
+ sha: master.object.sha
+ });
+ branchSha = newBranch.object.sha;
+ }
- - name: Upload zoomed in screenshot of the colored icons gotten from the artifacts
- id: colored_icons_detailed_img_step
- uses: devicons/public-upload-to-imgur@v2.2.2
- if: env.PEEK_STATUS == 'success' && success()
- with:
- path: ./screenshots/new_colored_icon_*.png
- client_id: ${{secrets.IMGUR_CLIENT_ID}}
+ // Get current tree SHA
+ const { data: commit } = await github.rest.git.getCommit({
+ ...repo,
+ commit_sha: branchSha
+ });
- - name: Comment on the PR about the result - Success
- uses: jungwinter/comment@v1.1.0 # let us comment on a specific PR
- if: env.PEEK_STATUS == 'success' && success()
- env:
- MESSAGE: |
- Hi there,
-
- I'm Devicons' Peek Bot and I just peeked at the icons that you wanted to add using [icomoon.io](https://icomoon.io/app/#/select).
+ // Upload each screenshot as a blob
+ const blobs = await Promise.all(files.map(async f => {
+ const content = fs.readFileSync(path.join(screenshotsDir, f));
+ const { data: blob } = await github.rest.git.createBlob({
+ ...repo,
+ content: content.toString('base64'),
+ encoding: 'base64'
+ });
+ return { path: `pr-${prNum}/${f}`, sha: blob.sha, mode: '100644', type: 'blob' };
+ }));
- Here are the SVGs as intepreted by Icomoon when we upload the files:
- {0}
+ // Create new tree and commit
+ const { data: tree } = await github.rest.git.createTree({
+ ...repo,
+ base_tree: commit.tree.sha,
+ tree: blobs
+ });
- Here are the zoomed-in screenshots of the added icons as **SVGs**:
- {1}
+ const { data: newCommit } = await github.rest.git.createCommit({
+ ...repo,
+ message: `screenshots: add PR #${prNum} previews`,
+ tree: tree.sha,
+ parents: [branchSha]
+ });
- Here are the icons that will be generated by Icomoon:
- {2}
+ await github.rest.git.updateRef({
+ ...repo,
+ ref: `heads/${branch}`,
+ sha: newCommit.sha
+ });
- Here are the zoomed-in screenshots of the added icons as **icons**:
- {3}
+ const baseUrl = `https://raw.githubusercontent.com/${repo.owner}/${repo.repo}/${branch}/pr-${prNum}`;
+ core.setOutput('base_url', baseUrl);
+ core.setOutput('files', files.join(','));
+ console.log(`Uploaded ${files.length} screenshots to ${baseUrl}`);
- Here are the colored versions:
- {4}
- {5}
+ - name: Comment on the PR - Success
+ if: env.PEEK_STATUS == 'success' && success()
+ uses: actions/github-script@v7
+ env:
+ BASE_URL: ${{ steps.upload_screenshots.outputs.base_url }}
+ FILES: ${{ steps.upload_screenshots.outputs.files }}
+ ERR_MSG: ${{ steps.err_message_reader.outputs.err_message }}
+ with:
+ script: |
+ const prNum = parseInt('${{ steps.pr_num_reader.outputs.pr_num }}');
+ const baseUrl = process.env.BASE_URL;
+ const files = process.env.FILES.split(',');
+ const errMsg = process.env.ERR_MSG || '';
- The maintainers will now check for:
- 1. The number of Glyphs matches the number of SVGs that were selected.
- 2. The icons (second group of pictures) look the same as the SVGs (first group of pictures).
- 3. The icons are of high quality (legible, matches the official logo, etc.)
- 4. A new object is added in the `devicon.json` file at the correct alphabetic position as seen [here](https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60)
+ const img = (name) => files.includes(name)
+ ? ``
+ : '_not generated_';
- In case of font issues, it might be caused by Icomoon not accepting strokes in the SVGs. Check this [doc](https://icomoon.io/#faq/importing) for more details and fix the issues as instructed by Icomoon and update this PR once you are done.
+ const imgGroup = (prefix) => files
+ .filter(f => f.startsWith(prefix))
+ .map(f => ``)
+ .join(' \n');
- Thank you for contributing to Devicon! I hope that your icons are accepted into the repository.
+ const body = [
+ 'Hi there,',
+ '',
+ "I'm Devicons' Peek Bot and I just peeked at the icons that you wanted to add using [icomoon.io](https://icomoon.io/app/#/select).",
+ '',
+ 'Here are the SVGs as interpreted by Icomoon when we upload the files:',
+ img('new_svgs.png'),
+ '',
+ 'Here are the zoomed-in screenshots of the added icons as **SVGs**:',
+ imgGroup('new_svg_'),
+ '',
+ 'Here are the icons that will be generated by Icomoon:',
+ img('new_icons.png'),
+ '',
+ 'Here are the zoomed-in screenshots of the added icons as **icons**:',
+ imgGroup('new_icon_'),
+ '',
+ 'Here are the colored versions:',
+ imgGroup('new_colored_icon_'),
+ '',
+ errMsg ? errMsg : '',
+ '',
+ 'The maintainers will now check for:',
+ '1. The number of Glyphs matches the number of SVGs that were selected.',
+ '2. The icons (second group of pictures) look the same as the SVGs (first group of pictures).',
+ '3. The icons are of high quality (legible, matches the official logo, etc.)',
+ '4. A new object is added in the `devicon.json` file at the correct alphabetic position as seen [here](https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60)',
+ '',
+ 'In case of font issues, it might be caused by Icomoon not accepting strokes in the SVGs. Check this [doc](https://icomoon.io/#faq/importing) for more details.',
+ '',
+ 'Thank you for contributing to Devicon!',
+ '',
+ 'Cheers,',
+ 'Peek Bot π',
+ ].join('\n');
- Note: If the images don't show up, it has been autodeleted by Imgur after 6 months due to our API choice.
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNum,
+ body
+ });
- Cheers,
- Peek Bot :blush:
- with:
- type: create
- issue_number: ${{ steps.pr_num_reader.outputs.content }}
- token: ${{ secrets.GITHUB_TOKEN }}
- body: >
- ${{
- format(
- env.MESSAGE,
- fromJSON(steps.svgs_overview_img_step.outputs.markdown_urls)[0],
- join(fromJSON(steps.svgs_detailed_img_step.outputs.markdown_urls), ' '),
- fromJSON(steps.icons_overview_img_step.outputs.markdown_urls)[0],
- join(fromJSON(steps.icons_detailed_img_step.outputs.markdown_urls), ' '),
- join(fromJSON(steps.colored_icons_detailed_img_step.outputs.markdown_urls), ' '),
- steps.err_message_reader.outputs.content
- )
- }}
-
- - name: Comment on the PR about the result - Failure
- if: env.PEEK_STATUS == 'failure'
- uses: jungwinter/comment@v1.1.0 # let us comment on a specific PR
+ - name: Comment on the PR - Peek failure
+ if: env.PEEK_STATUS == 'failure' && success()
+ uses: actions/github-script@v7
env:
- MESSAGE: |
- Hi there,
-
- I'm Devicons' Peek Bot and it seems we've ran into a problem.
-
- ```
- {0}
- ```
-
- Make sure that:
+ ERR_MSG: ${{ steps.err_message_reader.outputs.err_message }}
+ with:
+ script: |
+ const prNum = parseInt('${{ steps.pr_num_reader.outputs.pr_num }}');
+ const errMsg = process.env.ERR_MSG || 'Unknown error';
- - Your SVGs are named and added correctly to the /icons folder as seen [here](https://github.com/devicons/devicon/wiki/Organizing-SVGs).
- - Your icon information has been added to the `devicon.json` as seen [here](https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60)
- - Your PR title follows the format seen [here](https://github.com/devicons/devicon/wiki/Overview-on-Submitting-Icons)
+ const body = [
+ 'Hi there,',
+ '',
+ "I'm Devicons' Peek Bot and it seems we've run into a problem.",
+ '',
+ '```',
+ errMsg,
+ '```',
+ '',
+ 'Make sure that:',
+ '',
+ '- Your SVGs are named and added correctly to the /icons folder as seen [here](https://github.com/devicons/devicon/wiki/Organizing-SVGs).',
+ '- Your icon information has been added to the `devicon.json` as seen [here](https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60)',
+ '- Your PR title follows the format seen [here](https://github.com/devicons/devicon/wiki/Overview-on-Submitting-Icons)',
+ '',
+ 'I will retry once everything is fixed. If I still fail, the maintainers will investigate.',
+ '',
+ 'Best of luck,',
+ 'Peek Bot π',
+ ].join('\n');
- I will retry once everything is fixed. If I still fail or there are other error, the maintainers will investigate.
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNum,
+ body
+ });
- Best of luck,
- Peek Bot :relaxed:
- with:
- type: create
- issue_number: ${{ steps.pr_num_reader.outputs.content }}
- token: ${{ secrets.GITHUB_TOKEN }}
- body: ${{ format(env.MESSAGE, steps.err_message_reader.outputs.content) }}
- - name: Comment on the PR about the result - Failure
+ - name: Comment on the PR - Workflow error
if: failure()
- uses: jungwinter/comment@v1.1.0 # let us comment on a specific PR
- env:
- MESSAGE: |
- Hi there,
+ uses: actions/github-script@v7
+ with:
+ script: |
+ let prNum;
+ try {
+ prNum = parseInt('${{ steps.pr_num_reader.outputs.pr_num }}');
+ if (!prNum) throw new Error('no pr_num');
+ } catch (e) {
+ core.warning('Could not read PR number β skipping comment');
+ return;
+ }
- I'm Devicons' Peek Bot and we've ran into a problem with the `post_peek_screenshot` workflow.
- The maintainers will take a look and fix the issue. Please wait for further instructions.
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- Thank you,
- Peek Bot :relaxed:
- with:
- type: create
- issue_number: ${{ steps.pr_num_reader.outputs.content }}
- token: ${{ secrets.GITHUB_TOKEN }}
- body: env.MESSAGE
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNum,
+ body: [
+ 'Hi there,',
+ '',
+ "I'm Devicons' Peek Bot and we've run into a problem with the `post_peek_screenshot` workflow.",
+ `The maintainers will take a look at the [workflow run](${runUrl}) and fix the issue.`,
+ '',
+ 'Thank you,',
+ 'Peek Bot π',
+ ].join('\n')
+ });
From 446fc0c6f1756ec48be78933755f7c32c40310ff Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 04:54:39 +0200
Subject: [PATCH 07/29] chore: add comment to trigger workflow re-indexing
---
.github/workflows/build_nightly.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index abb7f86c0..81bcdc4d9 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -1,4 +1,5 @@
name: Nightly Build and Release
+# Runs daily at 2 AM UTC, or manually via workflow_dispatch
on:
schedule:
From 789f8f830af98d637d2303cb7e0003025d6b3d31 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 04:58:18 +0200
Subject: [PATCH 08/29] fix: use correct range when no previous build commit
exists
RANGE='HEAD' gives git diff nothing to compare against.
Use 'first-commit..HEAD' to cover all history on first run.
---
.github/workflows/build_nightly.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index 81bcdc4d9..f396259b9 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -40,8 +40,8 @@ jobs:
LAST_BUILD=$(git log --format="%H %s" | awk '/^[a-f0-9]+ build:/ { print $1; exit }')
if [ -z "$LAST_BUILD" ]; then
- echo "No previous build commit found β checking all history"
- RANGE="HEAD"
+ echo "No previous build commit found - checking all history"
+ RANGE="$(git rev-list --max-parents=0 HEAD)..HEAD"
else
echo "Last build commit: $LAST_BUILD"
RANGE="${LAST_BUILD}..HEAD"
From d147a0f4ac258df1b2e626afff6f0338ce54eefa Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 05:02:07 +0200
Subject: [PATCH 09/29] fix: fall back to last release tag when no build commit
exists
Priority order for determining range:
1. Last 'build:' prefixed commit (normal case)
2. Last vX.Y.Z release tag (first run after migration)
3. First commit in repo (truly fresh repo with no releases)
---
.github/workflows/build_nightly.yml | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index f396259b9..44be7a8e7 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -40,8 +40,15 @@ jobs:
LAST_BUILD=$(git log --format="%H %s" | awk '/^[a-f0-9]+ build:/ { print $1; exit }')
if [ -z "$LAST_BUILD" ]; then
- echo "No previous build commit found - checking all history"
- RANGE="$(git rev-list --max-parents=0 HEAD)..HEAD"
+ echo "No previous build commit found - falling back to last release tag"
+ LAST_TAG=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -1)
+ if [ -z "$LAST_TAG" ]; then
+ echo "No release tag found either - checking all history"
+ RANGE="$(git rev-list --max-parents=0 HEAD)..HEAD"
+ else
+ echo "Using last release tag as base: $LAST_TAG"
+ RANGE="${LAST_TAG}..HEAD"
+ fi
else
echo "Last build commit: $LAST_BUILD"
RANGE="${LAST_BUILD}..HEAD"
From c6d5f2fb9516c5a68130ac3f3c77c6d0ef082452 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 05:12:41 +0200
Subject: [PATCH 10/29] fix: install Firefox + chmod geckodriver, auto-create
build-failed label
- Add 'Install Firefox' step via apt before Icomoon build
- Add 'Make geckodriver executable' step (chmod +x)
- Auto-create 'build-failed' label if it doesn't exist before creating issue
- Fix core.setFailed message (remove em dash)
---
.github/workflows/build_nightly.yml | 29 ++++++++++++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index 44be7a8e7..355b70e3a 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -149,6 +149,16 @@ jobs:
if: steps.check-changes.outputs.has_changes == 'true'
run: npm install
+ - name: Install Firefox
+ if: steps.check-changes.outputs.has_changes == 'true'
+ run: |
+ sudo apt-get update -q
+ sudo apt-get install -y firefox
+
+ - name: Make geckodriver executable
+ if: steps.check-changes.outputs.has_changes == 'true'
+ run: chmod +x ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver
+
- name: Run Icomoon build
if: steps.check-changes.outputs.has_changes == 'true'
id: build
@@ -179,6 +189,23 @@ jobs:
const today = new Date().toISOString().split('T')[0];
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ // Ensure the build-failed label exists
+ try {
+ await github.rest.issues.getLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: 'build-failed'
+ });
+ } catch (e) {
+ await github.rest.issues.createLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: 'build-failed',
+ color: 'd73a4a',
+ description: 'Nightly build failed'
+ });
+ }
+
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -187,7 +214,7 @@ jobs:
labels: ['build-failed']
});
- core.setFailed('Nightly build failed β issue created');
+ core.setFailed('Nightly build failed - issue created');
- name: Commit build artifacts
if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
From aa3fe3356175a4c950952eed3579a7fcde4dd1df Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 05:30:51 +0200
Subject: [PATCH 11/29] debug: add log.txt artifact upload to nightly build
---
.github/workflows/build_nightly.yml | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index 355b70e3a..5ee499e42 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -181,6 +181,20 @@ jobs:
continue-on-error: true
run: npm run build-css
+ - name: Upload build log
+ uses: actions/upload-artifact@v4
+ if: always() && steps.check-changes.outputs.has_changes == 'true'
+ with:
+ name: build-log
+ path: ./log.txt
+
+ - name: Upload geckodriver log on failure
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: geckodriver-log
+ path: ./geckodriver.log
+
- name: Handle build failure
if: steps.check-changes.outputs.has_changes == 'true' && (steps.build.outcome == 'failure' || steps.build-css.outcome == 'failure')
uses: actions/github-script@v7
From 2673b0b1a110ae6cc9629a8e8e63728eef5ab3d8 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 15:34:14 +0200
Subject: [PATCH 12/29] fix: gracefully handle API failures in
get_icons_for_building
Wrap get_merged_pull_reqs_since_last_release in try-except so that
if the GitHub API call fails (e.g. in test environments where the
hardcoded devicons/devicon URL returns errors), we fall back to
find_new_icons_in_devicon_json which compares devicon.json vs icomoon.json directly.
---
.github/scripts/icomoon_build.py | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/.github/scripts/icomoon_build.py b/.github/scripts/icomoon_build.py
index 3dafe3a68..b3c8baa3c 100644
--- a/.github/scripts/icomoon_build.py
+++ b/.github/scripts/icomoon_build.py
@@ -80,14 +80,17 @@ def get_icons_for_building(icomoon_json_path: str, devicon_json_path: str, token
from the `devicon.json`.
"""
devicon_json = filehandler.get_json_file_content(devicon_json_path)
- pull_reqs = api_handler.get_merged_pull_reqs_since_last_release(token, logfile)
new_icons = []
- for pull_req in pull_reqs:
- if api_handler.is_feature_icon(pull_req):
- filtered_icon = util.find_object_added_in_pr(devicon_json, pull_req["title"])
- if filtered_icon not in new_icons:
- new_icons.append(filtered_icon)
+ try:
+ pull_reqs = api_handler.get_merged_pull_reqs_since_last_release(token, logfile)
+ for pull_req in pull_reqs:
+ if api_handler.is_feature_icon(pull_req):
+ filtered_icon = util.find_object_added_in_pr(devicon_json, pull_req["title"])
+ if filtered_icon not in new_icons:
+ new_icons.append(filtered_icon)
+ except Exception as e:
+ print(f"Warning: could not fetch PRs from GitHub API: {e}. Falling back to devicon.json diff.", file=logfile)
# get any icons that might not have been found by the API
# sometimes happen due to the PR being opened before the latest build release
From 5005f7ffab7d6b4d67a647a16f867eee7fb8106b Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 15:51:22 +0200
Subject: [PATCH 13/29] fix: use browser-actions/setup-firefox and
icomoon_build_githubless.py
- Replace apt-get firefox (snap, broken with geckodriver) with
browser-actions/setup-firefox (proper .deb binary)
- Switch from icomoon_build.py to icomoon_build_githubless.py which
needs no GitHub token and uses devicon.json vs icomoon.json diff directly
---
.github/workflows/build_nightly.yml | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index 5ee499e42..3aa22bac3 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -151,9 +151,7 @@ jobs:
- name: Install Firefox
if: steps.check-changes.outputs.has_changes == 'true'
- run: |
- sudo apt-get update -q
- sudo apt-get install -y firefox
+ uses: browser-actions/setup-firefox@v1
- name: Make geckodriver executable
if: steps.check-changes.outputs.has_changes == 'true'
@@ -163,16 +161,13 @@ jobs:
if: steps.check-changes.outputs.has_changes == 'true'
id: build
continue-on-error: true
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- python ./.github/scripts/icomoon_build.py \
+ python ./.github/scripts/icomoon_build_githubless.py \
./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
./icomoon.json \
./devicon.json \
./icons \
./ \
- $GITHUB_TOKEN \
--headless
- name: Build CSS
From 0832a5b012bc2b9fd4ea0b06f1db9189c4eb7f9a Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 16:12:46 +0200
Subject: [PATCH 14/29] debug: add screenshots + fallback selector to
SeleniumRunner
- Take screenshot on page load so we can see icomoon.io current state
- Try original 'icon-menu' selector first (6s), fall back to 'input[type=file]'
- Take screenshot when fallback is triggered and on failure
- Upload screenshots as build artifact in build_nightly.yml
---
.../selenium_runner/SeleniumRunner.py | 33 ++++++++++++++++---
.github/workflows/build_nightly.yml | 8 +++++
2 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
index 680344c78..f05322958 100644
--- a/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
@@ -160,11 +160,34 @@ def set_browser_options(self, download_path: str, geckodriver_path: str,
self.driver = self.create_driver_instance(options, geckodriver_path)
self.driver.get(self.ICOMOON_URL)
- # wait until the whole web page is loaded by testing the hamburger input
- WebDriverWait(self.driver, self.LONG_WAIT_IN_SEC).until(
- ec.element_to_be_clickable((By.XPATH, "(//i[@class='icon-menu'])[2]"))
- )
- print("Accessed icomoon.io", file=self.log_output)
+
+ # Take a screenshot so we can see what the page looks like if something goes wrong
+ self.driver.save_screenshot("./screenshots/icomoon_page_load.png")
+ print(f"Page title after load: {self.driver.title}", file=self.log_output)
+ print(f"Page source length: {len(self.driver.page_source)} chars", file=self.log_output)
+
+ # wait until the whole web page is loaded
+ # Try original selector first, fall back to a more generic check
+ try:
+ WebDriverWait(self.driver, self.MED_WAIT_IN_SEC).until(
+ ec.element_to_be_clickable((By.XPATH, "(//i[@class='icon-menu'])[2]"))
+ )
+ print("Accessed icomoon.io (via icon-menu selector)", file=self.log_output)
+ except SeleniumTimeoutException:
+ print("icon-menu selector not found, trying fallback selectors...", file=self.log_output)
+ self.driver.save_screenshot("./screenshots/icomoon_fallback_attempt.png")
+ # Fallback: wait for any file input (the core upload mechanism)
+ try:
+ WebDriverWait(self.driver, self.LONG_WAIT_IN_SEC).until(
+ ec.presence_of_element_located((By.CSS_SELECTOR, "input[type='file']"))
+ )
+ print("Accessed icomoon.io (via file input fallback)", file=self.log_output)
+ except SeleniumTimeoutException:
+ self.driver.save_screenshot("./screenshots/icomoon_load_failed.png")
+ raise Exception(
+ f"Could not load icomoon.io. Page title: '{self.driver.title}'. "
+ "The page may have changed structure or blocked headless browsing."
+ )
def create_driver_instance(self, options: Options, geckodriver_path: str):
"""
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index 3aa22bac3..058aff8c2 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -183,6 +183,14 @@ jobs:
name: build-log
path: ./log.txt
+ - name: Upload screenshots
+ uses: actions/upload-artifact@v4
+ if: always() && steps.check-changes.outputs.has_changes == 'true'
+ with:
+ name: build-screenshots
+ path: ./screenshots/
+ if-no-files-found: ignore
+
- name: Upload geckodriver log on failure
uses: actions/upload-artifact@v4
if: failure()
From 827f1ffcbd13ec2b29719000c8d612423a6a27ae Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 16:34:49 +0200
Subject: [PATCH 15/29] fix: use full-page screenshot to avoid canvas taint
error
element.screenshot() fails with 'Unable to capture screenshot' when
the element contains a cross-origin canvas (icomoon.io uses canvas
for icon rendering). driver.save_screenshot() bypasses this.
---
.../build_assets/selenium_runner/BuildSeleniumRunner.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
index 4cc81564a..06ebaed93 100644
--- a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
@@ -117,7 +117,8 @@ def take_icon_screenshot(self, screenshot_folder: str):
# wait a bit for all the icons to load before we take a pic
time.sleep(SeleniumRunner.MED_WAIT_IN_SEC)
- main_content.screenshot(new_icons_path)
+ # Use full-page screenshot to avoid canvas taint restrictions
+ self.driver.save_screenshot(new_icons_path)
print("Saved screenshot of the new icons...", file=self.log_output)
def go_to_generate_font_page(self):
From 5b6dc67f2d165418f9b38533321d59babab70b20 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 17:21:56 +0200
Subject: [PATCH 16/29] fix: increase icomoon.io fallback page-load timeout to
90s
The Angular app on icomoon.io can be slow to initialize.
LONG_WAIT_IN_SEC (25s) was too short on slow runners.
90s gives enough headroom for the page to fully render.
---
.../build_assets/selenium_runner/SeleniumRunner.py | 13 ++++---------
1 file changed, 4 insertions(+), 9 deletions(-)
diff --git a/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
index f05322958..d3fb62766 100644
--- a/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
@@ -161,11 +161,6 @@ def set_browser_options(self, download_path: str, geckodriver_path: str,
self.driver.get(self.ICOMOON_URL)
- # Take a screenshot so we can see what the page looks like if something goes wrong
- self.driver.save_screenshot("./screenshots/icomoon_page_load.png")
- print(f"Page title after load: {self.driver.title}", file=self.log_output)
- print(f"Page source length: {len(self.driver.page_source)} chars", file=self.log_output)
-
# wait until the whole web page is loaded
# Try original selector first, fall back to a more generic check
try:
@@ -175,17 +170,17 @@ def set_browser_options(self, download_path: str, geckodriver_path: str,
print("Accessed icomoon.io (via icon-menu selector)", file=self.log_output)
except SeleniumTimeoutException:
print("icon-menu selector not found, trying fallback selectors...", file=self.log_output)
- self.driver.save_screenshot("./screenshots/icomoon_fallback_attempt.png")
- # Fallback: wait for any file input (the core upload mechanism)
+ # Fallback: wait up to 90s for any file input (the core upload mechanism)
+ # icomoon.io can be slow to initialize the Angular app
try:
- WebDriverWait(self.driver, self.LONG_WAIT_IN_SEC).until(
+ WebDriverWait(self.driver, 90).until(
ec.presence_of_element_located((By.CSS_SELECTOR, "input[type='file']"))
)
print("Accessed icomoon.io (via file input fallback)", file=self.log_output)
except SeleniumTimeoutException:
self.driver.save_screenshot("./screenshots/icomoon_load_failed.png")
raise Exception(
- f"Could not load icomoon.io. Page title: '{self.driver.title}'. "
+ f"Could not load icomoon.io after 90s. Page title: '{self.driver.title}'. "
"The page may have changed structure or blocked headless browsing."
)
From 162cc4af97b0b457f99798d1522f8af4b8a9857d Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 18:25:25 +0200
Subject: [PATCH 17/29] fix: make icomoon hamburger menu operations non-fatal
in build
select_all_icons_in_top_set and deselect_all_icons_in_top_set use
fragile icomoon.io UI selectors that fail intermittently. These are
only needed for screenshots/visual ordering, not for actual font
generation. Wrap in try-except so the build continues even if they fail.
---
.../selenium_runner/BuildSeleniumRunner.py | 22 +++++++++++++------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
index 06ebaed93..a80c54e91 100644
--- a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
@@ -14,9 +14,11 @@ class BuildSeleniumRunner(SeleniumRunner):
def build_icons(self, icomoon_json_path: str,
zip_path: Path, svgs: List[str], screenshot_folder: str):
self.upload_icomoon(icomoon_json_path)
- # necessary so we can take screenshot of only the
- # recently uploaded icons later
- self.deselect_all_icons_in_top_set()
+ # try to deselect existing icons for cleaner screenshots, but non-fatal
+ try:
+ self.deselect_all_icons_in_top_set()
+ except Exception as e:
+ print(f"Warning: could not deselect icons (non-fatal): {e}", file=self.log_output)
self.upload_svgs(svgs, screenshot_folder)
self.take_icon_screenshot(screenshot_folder)
self.download_icomoon_fonts(zip_path)
@@ -91,9 +93,12 @@ def upload_svgs(self, svgs: List[str], screenshot_folder: str):
raise Exception(message + '\n'.join(err_messages))
# take a screenshot of the svgs that were just added
- # select the latest icons
- self.switch_toolbar_option(IcomoonOptionState.SELECT)
- self.select_all_icons_in_top_set()
+ # try to select the latest icons for a cleaner screenshot, but non-fatal
+ try:
+ self.switch_toolbar_option(IcomoonOptionState.SELECT)
+ self.select_all_icons_in_top_set()
+ except Exception as e:
+ print(f"Warning: could not select icons for screenshot (non-fatal): {e}", file=self.log_output)
new_svgs_path = str(Path(screenshot_folder, "new_svgs.png").resolve())
self.driver.save_screenshot(new_svgs_path)
@@ -146,7 +151,10 @@ def download_icomoon_fonts(self, zip_path: Path):
if self.current_page != IcomoonPage.SELECTION:
self.go_to_page(IcomoonPage.SELECTION)
- self.select_all_icons_in_top_set()
+ try:
+ self.select_all_icons_in_top_set()
+ except Exception as e:
+ print(f"Warning: could not select all icons (non-fatal, icons may already be selected): {e}", file=self.log_output)
self.go_to_generate_font_page()
download_btn = WebDriverWait(self.driver, SeleniumRunner.LONG_WAIT_IN_SEC).until(
From 87cb39237157a5561fb6032ea17935499269ddee Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 18:35:33 +0200
Subject: [PATCH 18/29] fix: remove deselect_all from build flow to prevent
empty font generation
deselect_all succeeded but select_all failed intermittently, leaving
0 icons selected and generating an empty font. Icons from uploaded
icomoon.json and new SVGs are selected by default in icomoon.io,
so deselecting is not needed for correct font generation.
Screenshots will show all icons (not just new ones) but font is correct.
---
.../build_assets/selenium_runner/BuildSeleniumRunner.py | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
index a80c54e91..e8564f36e 100644
--- a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
@@ -14,11 +14,9 @@ class BuildSeleniumRunner(SeleniumRunner):
def build_icons(self, icomoon_json_path: str,
zip_path: Path, svgs: List[str], screenshot_folder: str):
self.upload_icomoon(icomoon_json_path)
- # try to deselect existing icons for cleaner screenshots, but non-fatal
- try:
- self.deselect_all_icons_in_top_set()
- except Exception as e:
- print(f"Warning: could not deselect icons (non-fatal): {e}", file=self.log_output)
+ # Note: intentionally NOT calling deselect_all_icons_in_top_set() here.
+ # Deselecting then failing to re-select leaves 0 icons selected β empty font.
+ # Icons loaded from icomoon.json and newly uploaded SVGs are selected by default.
self.upload_svgs(svgs, screenshot_folder)
self.take_icon_screenshot(screenshot_folder)
self.download_icomoon_fonts(zip_path)
From b32a4bad504c456b3afa871002a2ac779632ed12 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 19:25:28 +0200
Subject: [PATCH 19/29] fix: scope peek screenshots to new icons only; add
release approval gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Peek bot (icomoon_peek.py / PeekSeleniumRunner):
- Fix click_hamburger_input to target the first set header dynamically
instead of the hardcoded #setH2 id, which was pointing at the wrong
(IcoMoon Free) set and causing select_all to select the wrong icons.
- Remove new_svgs.png full-panel overview from peek_svgs β the per-SVG
edit_svg shots (new_svg_*.png) taken during upload are already scoped
correctly and are not affected by the set-selector bug.
- Remove new_icons.png full-panel overview from peek_icons and cap the
glyphSet0 iteration to num_svgs entries as a hard guard against
default-set icons bleeding into the font preview.
- PR comment no longer references the two removed overview images.
Build runner (BuildSeleniumRunner):
- Call deselect_all_icons_in_top_set before uploading new SVGs so that
only new icons are selected for the build screenshot. download_icomoon_fonts
calls select_all before font generation, so the full set is still used.
- Remove non-fatal try/except wrappers now that the hamburger selector fix
makes the operations reliable.
build_nightly.yml:
- Split single job into build + release. The release job targets the
production GitHub Environment so maintainers receive an approval request
before the version bump and GitHub release are published.
- Release is now published (not draft) once approved.
Co-Authored-By: Claude Sonnet 4.6
---
.../selenium_runner/BuildSeleniumRunner.py | 28 +++----
.../selenium_runner/PeekSeleniumRunner.py | 52 ++++---------
.../selenium_runner/SeleniumRunner.py | 5 +-
.github/workflows/build_nightly.yml | 77 +++++++++++++++----
.github/workflows/post_peek_screenshot.yml | 8 +-
5 files changed, 97 insertions(+), 73 deletions(-)
diff --git a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
index e8564f36e..26836bda6 100644
--- a/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/BuildSeleniumRunner.py
@@ -14,9 +14,12 @@ class BuildSeleniumRunner(SeleniumRunner):
def build_icons(self, icomoon_json_path: str,
zip_path: Path, svgs: List[str], screenshot_folder: str):
self.upload_icomoon(icomoon_json_path)
- # Note: intentionally NOT calling deselect_all_icons_in_top_set() here.
- # Deselecting then failing to re-select leaves 0 icons selected β empty font.
- # Icons loaded from icomoon.json and newly uploaded SVGs are selected by default.
+ # Deselect all existing icons BEFORE uploading new SVGs.
+ # Newly uploaded SVGs are auto-selected by icomoon.io, so after this
+ # only the new icons will be selected β giving clean "new icons only" screenshots.
+ # download_icomoon_fonts() calls select_all before generating the font,
+ # so the full icon set is always used for font generation.
+ self.deselect_all_icons_in_top_set()
self.upload_svgs(svgs, screenshot_folder)
self.take_icon_screenshot(screenshot_folder)
self.download_icomoon_fonts(zip_path)
@@ -90,13 +93,10 @@ def upload_svgs(self, svgs: List[str], screenshot_folder: str):
message = "BuildSeleniumRunner - Issues found when uploading SVGs:\n"
raise Exception(message + '\n'.join(err_messages))
- # take a screenshot of the svgs that were just added
- # try to select the latest icons for a cleaner screenshot, but non-fatal
- try:
- self.switch_toolbar_option(IcomoonOptionState.SELECT)
- self.select_all_icons_in_top_set()
- except Exception as e:
- print(f"Warning: could not select icons for screenshot (non-fatal): {e}", file=self.log_output)
+ # Take a screenshot of only the newly uploaded SVGs.
+ # Because we called deselect_all before uploading, only the new icons
+ # are selected at this point β no need to call select_all here.
+ self.switch_toolbar_option(IcomoonOptionState.SELECT)
new_svgs_path = str(Path(screenshot_folder, "new_svgs.png").resolve())
self.driver.save_screenshot(new_svgs_path)
@@ -149,10 +149,10 @@ def download_icomoon_fonts(self, zip_path: Path):
if self.current_page != IcomoonPage.SELECTION:
self.go_to_page(IcomoonPage.SELECTION)
- try:
- self.select_all_icons_in_top_set()
- except Exception as e:
- print(f"Warning: could not select all icons (non-fatal, icons may already be selected): {e}", file=self.log_output)
+ # select_all is required here β after deselect_all + new SVG uploads,
+ # only the new icons are selected. We must select everything before
+ # generating the font or the existing icons will be missing from the output.
+ self.select_all_icons_in_top_set()
self.go_to_generate_font_page()
download_btn = WebDriverWait(self.driver, SeleniumRunner.LONG_WAIT_IN_SEC).until(
diff --git a/.github/scripts/build_assets/selenium_runner/PeekSeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/PeekSeleniumRunner.py
index eb1fa6441..2726d7518 100644
--- a/.github/scripts/build_assets/selenium_runner/PeekSeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/PeekSeleniumRunner.py
@@ -10,13 +10,13 @@ def peek(self, svgs: List[str], screenshot_folder: str, icon_info: dict):
Upload the SVGs and peek at how Icomoon interpret its SVGs and
font versions.
:param svgs: a list of svg Paths that we'll upload to icomoon.
- :param screenshot_folder: the name of the screenshot_folder.
+ :param screenshot_folder: the name of the screenshot_folder.
:param icon_info: a dictionary containing info on an icon. Taken from the devicon.json.
:return an array of svgs with strokes as strings. These show which icon
contains stroke.
"""
- messages = self.peek_svgs(svgs, screenshot_folder)
- self.peek_icons(screenshot_folder, icon_info)
+ messages = self.peek_svgs(svgs, screenshot_folder)
+ self.peek_icons(screenshot_folder, icon_info, len(svgs))
return messages
def peek_svgs(self, svgs: List[str], screenshot_folder: str):
@@ -52,24 +52,19 @@ def peek_svgs(self, svgs: List[str], screenshot_folder: str):
self.edit_svg(screenshot_folder, i)
- # take a screenshot of the svgs that were just added
- self.select_all_icons_in_top_set()
- new_svgs_path = str(Path(screenshot_folder, "new_svgs.png").resolve())
- icon_set_xpath = "/html/body/div[4]/div[1]/div[2]/div[1]"
- icon_set = self.driver.find_element_by_xpath(icon_set_xpath)
- icon_set.screenshot(new_svgs_path)
-
print("Finished peeking the svgs...", file=self.log_output)
return svgs_with_strokes
- def peek_icons(self, screenshot_folder: str, icon_info: dict):
+ def peek_icons(self, screenshot_folder: str, icon_info: dict, num_svgs: int):
"""
Peek at the icon versions of the SVGs that were uploaded.
- :param screenshot_folder: the name of the screenshot_folder.
+ :param screenshot_folder: the name of the screenshot_folder.
:param icon_info: a dictionary containing info on an icon. Taken from the devicon.json.
+ :param num_svgs: the number of SVGs that were uploaded (used to guard against
+ extra icons from icomoon's default sets leaking into the font preview).
"""
print("Begin peeking at the icons...", file=self.log_output)
- # ensure all icons in the set is selected.
+ # Select only the newly uploaded icons in the top set before generating font.
self.select_all_icons_in_top_set()
self.go_to_page(IcomoonPage.GENERATE_FONT)
alert = self.test_for_possible_alert(self.MED_WAIT_IN_SEC)
@@ -80,43 +75,28 @@ def peek_icons(self, screenshot_folder: str, icon_info: dict):
else:
raise Exception(f"Unexpected alert found: {alert}")
- # take an overall screenshot of the icons that were just added
- # also include the glyph count
- new_icons_path = str(Path(screenshot_folder, "new_icons.png").resolve())
- main_content_xpath = "/html/body/div[4]/div[2]/div/div[1]"
- main_content = self.driver.find_element_by_xpath(main_content_xpath)
- main_content.screenshot(new_icons_path);
-
- # go in reverse order so we get the oldest icon first
+ # Collect glyph divs; reverse so screenshots are ordered oldest-first
+ # (matching the SVG upload order). Cap at num_svgs as a safety guard
+ # in case any icons from icomoon's default sets bleed into glyphSet0.
icon_divs_xpath = f'//div[@id="glyphSet0"]/div'
- icon_divs = self.driver.find_elements_by_xpath(icon_divs_xpath)
- icon_divs.reverse()
- i = 0
- for icon_div in icon_divs:
- if not icon_div.is_displayed():
- continue
+ all_icon_divs = self.driver.find_elements_by_xpath(icon_divs_xpath)
+ all_icon_divs.reverse()
+ icon_divs = [d for d in all_icon_divs if d.is_displayed()][:num_svgs]
+ for i, icon_div in enumerate(icon_divs):
icon_screenshot = str(
Path(screenshot_folder, f"new_icon_{i}.png").resolve()
)
icon_div.screenshot(icon_screenshot)
- i += 1
-
# test the colors
style = "#glyphSet0 span:first-of-type {color: " + icon_info["color"] + "}"
script = f"document.styleSheets[0].insertRule('{style}', 0)"
self.driver.execute_script(script)
- i = 0
- for icon_div in icon_divs:
- if not icon_div.is_displayed():
- continue
-
+ for i, icon_div in enumerate(icon_divs):
icon_screenshot = str(
Path(screenshot_folder, f"new_colored_icon_{i}.png").resolve()
)
icon_div.screenshot(icon_screenshot)
- i += 1
-
print("Finished peeking the icons...", file=self.log_output)
diff --git a/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py b/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
index d3fb62766..fc91f55c0 100644
--- a/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
+++ b/.github/scripts/build_assets/selenium_runner/SeleniumRunner.py
@@ -245,7 +245,10 @@ def click_hamburger_input(self):
input two times before the menu appears.
:return: None.
"""
- top_set_hamburger_input_xpath = '//*[@id="setH2"]/button[1]/i'
+ # Target the first icon-menu button inside any h1 set header.
+ # icomoon always renders the most recently uploaded (top) set first in the DOM,
+ # so [1] reliably selects it without relying on a hardcoded element ID.
+ top_set_hamburger_input_xpath = '(//h1//i[@class="icon-menu"])[1]'
hamburger_input = self.driver.find_element_by_xpath(
top_set_hamburger_input_xpath
)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index 058aff8c2..a87c3fa84 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -1,9 +1,9 @@
-name: Nightly Build and Release
-# Runs daily at 2 AM UTC, or manually via workflow_dispatch
+name: Weekly Build and Release
+# Runs every Monday at 2 AM UTC, or manually via workflow_dispatch
on:
schedule:
- - cron: '0 2 * * *' # 2 AM UTC daily
+ - cron: '0 2 * * 1' # 2 AM UTC every Monday
workflow_dispatch:
inputs:
bump:
@@ -22,9 +22,13 @@ permissions:
issues: write
jobs:
- build-and-release:
- name: Build, Bump Version, and Release
+ build:
+ name: Build and Stage Release
runs-on: ubuntu-latest
+ outputs:
+ has_changes: ${{ steps.check-changes.outputs.has_changes }}
+ bump: ${{ steps.version-bump.outputs.bump }}
+ build_ok: ${{ steps.build-result.outputs.build_ok }}
steps:
- name: Checkout master
@@ -219,19 +223,31 @@ jobs:
repo: context.repo.repo,
name: 'build-failed',
color: 'd73a4a',
- description: 'Nightly build failed'
+ description: 'Weekly build failed'
});
}
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
- title: `Nightly build failed - ${today}`,
- body: `The nightly Icomoon build failed.\n\n**Workflow run:** ${runUrl}\n\nPlease investigate and re-run manually once fixed using the \`workflow_dispatch\` trigger on \`build_nightly.yml\`.`,
+ title: `Weekly build failed - ${today}`,
+ body: `The weekly Icomoon build failed.\n\n**Workflow run:** ${runUrl}\n\nPlease investigate and re-run manually once fixed using the \`workflow_dispatch\` trigger on \`build_nightly.yml\`.`,
labels: ['build-failed']
});
- core.setFailed('Nightly build failed - issue created');
+ core.setFailed('Weekly build failed - issue created');
+
+ - name: Set build result output
+ id: build-result
+ if: always()
+ run: |
+ if [[ "${{ steps.check-changes.outputs.has_changes }}" == "true" && \
+ "${{ steps.build.outcome }}" == "success" && \
+ "${{ steps.build-css.outcome }}" == "success" ]]; then
+ echo "build_ok=true" >> $GITHUB_OUTPUT
+ else
+ echo "build_ok=false" >> $GITHUB_OUTPUT
+ fi
- name: Commit build artifacts
if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
@@ -246,30 +262,61 @@ jobs:
echo "No build artifacts changed"
fi
+ # ββ Release gate βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # This job requires a manual approval in the "production" GitHub Environment
+ # before bumping the version and publishing the release.
+ #
+ # One-time setup (repo admin):
+ # Settings β Environments β New environment β name it "production"
+ # Add required reviewers (maintainers who should approve releases).
+ release:
+ name: Bump Version and Release
+ needs: build
+ # Only run when the build job found icon changes AND succeeded
+ if: needs.build.outputs.has_changes == 'true' && needs.build.outputs.build_ok == 'true'
+ runs-on: ubuntu-latest
+ environment: production
+
+ steps:
+ - name: Checkout master
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Node dependencies
+ run: npm install
+
- name: Bump version
- if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
id: bump
run: |
- BUMP="${{ steps.version-bump.outputs.bump }}"
+ BUMP="${{ needs.build.outputs.bump }}"
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
npm version $BUMP -m "chore: bump version to %s"
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
git push --follow-tags
- name: Create GitHub release
- if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const version = '${{ steps.bump.outputs.version }}';
- const bump = '${{ steps.version-bump.outputs.bump }}';
+ const bump = '${{ needs.build.outputs.bump }}';
await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `v${version}`,
name: `Release v${version}`,
- generate_release_notes: true
+ generate_release_notes: true,
+ draft: false
});
- console.log(`Release v${version} created (${bump} bump)`);
+ console.log(`Released v${version} (${bump} bump).`);
diff --git a/.github/workflows/post_peek_screenshot.yml b/.github/workflows/post_peek_screenshot.yml
index 0ce65677a..a0ac56201 100644
--- a/.github/workflows/post_peek_screenshot.yml
+++ b/.github/workflows/post_peek_screenshot.yml
@@ -162,16 +162,10 @@ jobs:
'',
"I'm Devicons' Peek Bot and I just peeked at the icons that you wanted to add using [icomoon.io](https://icomoon.io/app/#/select).",
'',
- 'Here are the SVGs as interpreted by Icomoon when we upload the files:',
- img('new_svgs.png'),
- '',
'Here are the zoomed-in screenshots of the added icons as **SVGs**:',
imgGroup('new_svg_'),
'',
- 'Here are the icons that will be generated by Icomoon:',
- img('new_icons.png'),
- '',
- 'Here are the zoomed-in screenshots of the added icons as **icons**:',
+ 'Here are the zoomed-in screenshots of the added icons as **font icons**:',
imgGroup('new_icon_'),
'',
'Here are the colored versions:',
From f5f211b71b125659df72d7f5229552325d24f152 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 16 May 2026 19:59:09 +0200
Subject: [PATCH 20/29] fix: exit cleanly when no new icons to build; gate
release on icomoon.json diff
icomoon_build_githubless.py: when devicon.json has no icons absent from
icomoon.json, return cleanly (exit 0) instead of calling sys.exit with a
message string (which exits 1 and triggers a false build-failure alert).
build_nightly.yml: change build_ok to require that icomoon.json was actually
modified during the run. The build script only writes to icomoon.json when new
icons are processed, so a clean git diff on that file is a reliable signal
that something was built. This prevents a version bump + release when commits
touch icons/ but contain no new icons (e.g. SVG attribute tweaks).
Co-Authored-By: Claude Sonnet 4.6
---
.github/scripts/icomoon_build_githubless.py | 5 ++++-
.github/workflows/build_nightly.yml | 6 +++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/.github/scripts/icomoon_build_githubless.py b/.github/scripts/icomoon_build_githubless.py
index 92fb1aba9..1a9ea9db5 100644
--- a/.github/scripts/icomoon_build_githubless.py
+++ b/.github/scripts/icomoon_build_githubless.py
@@ -22,7 +22,10 @@ def main():
args = arg_getters.get_selenium_runner_args(has_token=False)
new_icons = get_icons_for_building(args.icomoon_json_path, args.devicon_json_path, logfile)
if len(new_icons) == 0:
- sys.exit("No files need to be uploaded. Ending script...")
+ msg = "No new icons found in devicon.json that are not already in icomoon.json β nothing to build."
+ print(msg, file=logfile)
+ print(msg)
+ return
print(f"There are {len(new_icons)} icons to be build. Here are they:", *new_icons, sep = "\n", file=logfile)
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_nightly.yml
index a87c3fa84..14ffda6bf 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_nightly.yml
@@ -241,9 +241,13 @@ jobs:
id: build-result
if: always()
run: |
+ # build_ok=true only when the build + CSS succeeded AND icomoon.json was
+ # actually modified β the script only touches icomoon.json when new icons
+ # are processed, so this is a reliable "something was built" signal.
if [[ "${{ steps.check-changes.outputs.has_changes }}" == "true" && \
"${{ steps.build.outcome }}" == "success" && \
- "${{ steps.build-css.outcome }}" == "success" ]]; then
+ "${{ steps.build-css.outcome }}" == "success" ]] && \
+ ! git diff --quiet -- icomoon.json; then
echo "build_ok=true" >> $GITHUB_OUTPUT
else
echo "build_ok=false" >> $GITHUB_OUTPUT
From 8238870f4c419d18030657c9c15074cd92c37af4 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 14:54:58 +0200
Subject: [PATCH 21/29] chore: remove remaining develop-branch references
- Drop in-develop issue closing from icomoon_build.py (GitHub closes
linked issues when PRs merge to master) and its unused arg getter
- Replace README develop vs master section with trunk-based workflow
description; point Gitpod and PR template links at master
- Upgrade codeql-action to v3, fix stale Node.js step name
- Update docs/comments to reflect the weekly build schedule
Co-Authored-By: Claude Opus 5.5
---
.github/PULL_REQUEST_TEMPLATE/new_icon.md | 2 +-
.github/scripts/build_assets/arg_getters.py | 15 ------------
.github/scripts/icomoon_build.py | 5 ----
.github/workflows/build_icons.yml | 2 +-
.github/workflows/codeql-analysis.yml | 12 +++++-----
.github/workflows/npm_publish.yml | 2 +-
CLAUDE.md | 15 ++++--------
README.md | 26 +++++++--------------
8 files changed, 22 insertions(+), 57 deletions(-)
diff --git a/.github/PULL_REQUEST_TEMPLATE/new_icon.md b/.github/PULL_REQUEST_TEMPLATE/new_icon.md
index 9e35dc41e..7cd7fb33b 100644
--- a/.github/PULL_REQUEST_TEMPLATE/new_icon.md
+++ b/.github/PULL_REQUEST_TEMPLATE/new_icon.md
@@ -8,7 +8,7 @@
- [ ] SVG matches the standards laid out [here](https://github.com/devicons/devicon/wiki/SVG-Standards)
- [ ] A new object is added in the `devicon.json` file at the correct alphabetic position as seen [here](https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60)
-
+
**This PR closes NONE**
diff --git a/.github/scripts/build_assets/arg_getters.py b/.github/scripts/build_assets/arg_getters.py
index 8f82f0e6d..299839e08 100644
--- a/.github/scripts/build_assets/arg_getters.py
+++ b/.github/scripts/build_assets/arg_getters.py
@@ -71,18 +71,3 @@ def get_release_message_args():
parser.add_argument("token",
help="The GitHub token to access the GitHub REST API.")
return parser.parse_args()
-
-
-def get_in_develop_labeler_args():
- """
- Get the commandline arguments for in_develop_labeler.py.
- """
- parser = ArgumentParser(description="Parse the PR body to find the issue(s) we are labelling.")
- parser.add_argument("token",
- help="The GitHub token to access the GitHub REST API.",
- type=str)
-
- parser.add_argument("pr_num",
- help="The PR's number",
- type=str)
- return parser.parse_args()
diff --git a/.github/scripts/icomoon_build.py b/.github/scripts/icomoon_build.py
index b3c8baa3c..026c51918 100644
--- a/.github/scripts/icomoon_build.py
+++ b/.github/scripts/icomoon_build.py
@@ -52,11 +52,6 @@ def main():
print("Creating the release message by querying the GitHub API...", file=logfile)
get_release_message(args.token, logfile)
- print("Closing issues with the `in-develop` label.", file=logfile)
- issues = api_handler.get_issues_by_labels(args.token, ["in-develop"])
- issue_nums = [issue_num["number"] for issue_num in issues]
- api_handler.close_issues(args.token, issue_nums)
-
print("Task completed.", file=logfile)
except TimeoutException as e:
util.exit_with_err(Exception("Selenium Time Out Error: \n" + str(e)), logfile)
diff --git a/.github/workflows/build_icons.yml b/.github/workflows/build_icons.yml
index 773d5afbb..3f1266621 100644
--- a/.github/workflows/build_icons.yml
+++ b/.github/workflows/build_icons.yml
@@ -1,6 +1,6 @@
name: Build Icons (Emergency Fallback)
-# Manual emergency re-run for when the nightly build fails or needs to be
+# Manual emergency re-run for when the weekly build fails or needs to be
# forced outside the regular schedule. Commits font files directly to master
# with a "build:" prefix so build_nightly.yml doesn't double-process.
#
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 690492c3c..5774f3e09 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -18,12 +18,12 @@ jobs:
uses: actions/checkout@v4
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
CodeQL-Python-dot-github:
runs-on: ubuntu-latest
@@ -50,14 +50,14 @@ jobs:
echo "CODEQL_PYTHON=${{ steps.setup-python.outputs.python-path }}" >> $GITHUB_ENV
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: python
setup-python-dependencies: false
source-root: .github/
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
CodeQL-Python-rest:
runs-on: ubuntu-latest
@@ -76,10 +76,10 @@ jobs:
- name: Initialize CodeQL
if: ${{ steps.find_files.outputs.python_files != '0' }}
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: python
- name: Perform CodeQL Analysis
if: ${{ steps.find_files.outputs.python_files != '0' }}
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/npm_publish.yml b/.github/workflows/npm_publish.yml
index 9e0270424..8cd03096d 100644
--- a/.github/workflows/npm_publish.yml
+++ b/.github/workflows/npm_publish.yml
@@ -12,7 +12,7 @@ jobs:
# "ref" specifies the branch to check out.
# "github.event.release.target_commitish" is a global variable and specifies the branch the release targeted
ref: ${{ github.event.release.target_commitish }}
- - name: Use Node.js v16
+ - name: Use Node.js v24
uses: actions/setup-node@v4
with:
node-version: 24
diff --git a/CLAUDE.md b/CLAUDE.md
index d30d199df..16f7c5023 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,7 +34,7 @@ devicon/
- **Single branch:** `master` only. No `develop` branch.
- Contributors open PRs against `master`.
- Maintainers approve β auto-merge workflow fires.
-- Font files are built nightly, not per PR.
+- Font files are built weekly, not per PR.
## Workflow Architecture
@@ -43,11 +43,11 @@ devicon/
2. `peek_icons.yml` runs Icomoon peek β `post_peek_screenshot.yml` posts screenshots as PR comment
3. Maintainer approves β `auto_build_merge.yml` squash-merges to master (**no build step**)
-### Nightly Build (`build_nightly.yml`)
-- Runs daily at 2 AM UTC
+### Weekly Build (`build_nightly.yml`)
+- Runs every Monday at 2 AM UTC (also via `workflow_dispatch`)
- Detects icon changes since last `build:` commit
- Runs Icomoon build β commits font files with `build:` prefix
-- Determines semver bump from merged PR labels β bumps version β creates release
+- Determines semver bump from merged PR labels β release job (gated by `production` environment approval) bumps version β creates release
- On failure: creates a GitHub issue
### Version Bump Labels
@@ -102,10 +102,5 @@ Each entry looks like:
- Font files (`fonts/`, `devicon.eot`, `devicon.ttf`, etc.) are **generated artifacts** β don't edit them directly.
- `icomoon.json` is updated by the build script β don't edit manually.
- `devicon.min.css` is generated by `npm run build-css` β don't edit manually.
-- The `build:` commit prefix is used by the nightly build to detect the last successful build.
+- The `build:` commit prefix is used by the weekly build to detect the last successful build.
- `version_and_release.yml` is deprecated β versioning is handled by `build_nightly.yml`.
-
-## Deferred Issues (to fix later)
-- `codeql-analysis.yml`: upgrade `codeql-action@v2` β `@v3`
-- `npm_publish.yml`: comment says "Node.js v16" but uses v24 β fix comment
-- `PULL_REQUEST_TEMPLATE/new_icon.md`: link still points to `devicon/blob/develop/CONTRIBUTING.md` β update to `master`
diff --git a/README.md b/README.md
index 8ffb181e1..5db86a91e 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
- Requesting Icon
- Contributing
- Discord Community
- develop vs master
+ - Branching and Releases
- Stale Pull Requests
- Building Devicon
@@ -180,30 +180,20 @@ Add the following CSS rules in your stylesheet:
We have a Discord community for Devicons. You can easily request icons, discuss, and have a good time talking with the community members! Join the official Discord Server here!
-develop vs master
+Branching and Releases
-All official releases shall be in master. Any updates in between (icons, features, etc.) will be kept in develop.
+Devicon uses trunk-based development. master is the only long-lived branch, and all pull requests should target it.
-develop contains:
-
- Latest SVGs (non-optimized).
+ Approved pull requests are merged straight into
master.
-
- No icons for the latest SVGs. These will be built at every release.
- Can be built manually. See Building Devicon.
+ Font files and CSS for newly merged icons are built by a weekly job, so master can contain SVGs whose font icons have not been built yet.
+ You can also build them manually. See Building Devicon.
-
- Experimental changes.
-
-
-master contains:
-
- -
- Latest official release, which contains all the SVGs and icons.
-
- -
- Official, tested changes.
+ When a weekly build produces new icons, a maintainer approves the release, and the version is bumped and published.
@@ -232,7 +222,7 @@ Follow these steps to build the website and icons either locally or using Using Gitpod.io
By using Gitpod.io, you can easily build the icons and install the
required dependencies in one single click. No extra setup is required.
-
+
> **Note**
> In case some of the commands are not properly ran, you can\
From 2c20d60ae9ad1b3ee00bde07ad91fa3974671d8c Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 15:14:26 +0200
Subject: [PATCH 22/29] chore: rename build_nightly.yml to build_weekly.yml;
drop unused issue helpers
The workflow runs weekly, so the file name now matches. label_issues,
close_issues and get_issues_by_labels in api_handler.py were only used
by the removed in-develop flow.
Co-Authored-By: Claude Opus 5.5
---
.github/scripts/build_assets/api_handler.py | 78 -------------------
.github/workflows/build_icons.yml | 4 +-
.../{build_nightly.yml => build_weekly.yml} | 2 +-
.github/workflows/version_and_release.yml | 2 +-
CLAUDE.md | 4 +-
5 files changed, 6 insertions(+), 84 deletions(-)
rename .github/workflows/{build_nightly.yml => build_weekly.yml} (99%)
diff --git a/.github/scripts/build_assets/api_handler.py b/.github/scripts/build_assets/api_handler.py
index b11c2dc67..3350f3f5d 100644
--- a/.github/scripts/build_assets/api_handler.py
+++ b/.github/scripts/build_assets/api_handler.py
@@ -1,7 +1,6 @@
import requests
import sys
import re
-from typing import List
from io import FileIO
@@ -105,83 +104,6 @@ def find_all_authors(pull_req_data, token):
return ", ".join(["@" + author for author in list(authors)])
-def label_issues(token: str, issues: List[str], labels: List[str]):
- """
- Label the issues specified with the label specified.
- :param token: the GitHub API token.
- :param issues: the issue numbers (as str) that we are labelling.
- :param labels: the labels that we are labelling.
- """
- headers = {
- "Authorization": f"token {token}",
- "accept": "application/vnd.github.v3+json"
- }
- url = base_url + "issues/{}/labels"
- for issue in issues:
- body = {
- "labels": labels
- }
- response = requests.post(url.format(issue), headers=headers, json=body)
- if not response:
- raise Exception(f"Can't label the Issue provided. Issue: {issue}, labels: {labels}, API response: " + response.text)
- else:
- print(f"Successfully labelled issue {issue}")
-
-
-def close_issues(token: str, issues: List[str]):
- """
- Close issues.
- :param token: the GitHub API token.
- :param issues: the issue numbers (as str) that we are labelling.
- """
- headers = {
- "Authorization": f"token {token}",
- "accept": "application/vnd.github.v3+json"
- }
- url = base_url + "issues/{}"
- body = {
- "state": "closed"
- }
- for issue in issues:
- response = requests.patch(url.format(issue), headers=headers, json=body)
- if not response:
- raise Exception(f"Can't close Issue provided. Issue: {issue}, API response: " + response.text)
- else:
- print(f"Successfully closed issue {issue}")
-
-
-def get_issues_by_labels(token: str, labels: List[str]):
- """
- Get a list of issues based on their labels.
- :param token: the GitHub API token.
- :param labels: the labels that we are labelling.
- """
- url = base_url + "issues?per_page=100&labels={}&page={}"
- headers = {
- "Authorization": f"token {token}",
- "accept": "application/vnd.github.v3+json"
- }
- issues = []
- done = False
- page_num = 1
- while not done:
- response = requests.get(url.format(",".join(labels), page_num), headers=headers)
- if not response:
- raise Exception(f"Can't access API. Can't get issues for labels: {labels}, API response: " + response.text)
- else:
- results = response.json()
- if len(results) < 100:
- done = True # we are done
- else:
- page_num += 1 # page is full => might need to check another page
-
- # GitHub API also returns PRs for issues queries => have to check
- issues_only = [issue for issue in results if issue.get("pull_request") is None]
- issues.extend(issues_only)
-
- return issues
-
-
def get_pr_by_number(token: str, pr_num: str):
url = base_url + "pulls/" + pr_num
headers = {
diff --git a/.github/workflows/build_icons.yml b/.github/workflows/build_icons.yml
index 3f1266621..e1584d6fe 100644
--- a/.github/workflows/build_icons.yml
+++ b/.github/workflows/build_icons.yml
@@ -2,9 +2,9 @@ name: Build Icons (Emergency Fallback)
# Manual emergency re-run for when the weekly build fails or needs to be
# forced outside the regular schedule. Commits font files directly to master
-# with a "build:" prefix so build_nightly.yml doesn't double-process.
+# with a "build:" prefix so build_weekly.yml doesn't double-process.
#
-# For day-to-day releases, use build_nightly.yml instead.
+# For day-to-day releases, use build_weekly.yml instead.
on:
workflow_dispatch:
diff --git a/.github/workflows/build_nightly.yml b/.github/workflows/build_weekly.yml
similarity index 99%
rename from .github/workflows/build_nightly.yml
rename to .github/workflows/build_weekly.yml
index 14ffda6bf..804f8c67e 100644
--- a/.github/workflows/build_nightly.yml
+++ b/.github/workflows/build_weekly.yml
@@ -231,7 +231,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
title: `Weekly build failed - ${today}`,
- body: `The weekly Icomoon build failed.\n\n**Workflow run:** ${runUrl}\n\nPlease investigate and re-run manually once fixed using the \`workflow_dispatch\` trigger on \`build_nightly.yml\`.`,
+ body: `The weekly Icomoon build failed.\n\n**Workflow run:** ${runUrl}\n\nPlease investigate and re-run manually once fixed using the \`workflow_dispatch\` trigger on \`build_weekly.yml\`.`,
labels: ['build-failed']
});
diff --git a/.github/workflows/version_and_release.yml b/.github/workflows/version_and_release.yml
index 6b5a9f245..391e93f5a 100644
--- a/.github/workflows/version_and_release.yml
+++ b/.github/workflows/version_and_release.yml
@@ -1,6 +1,6 @@
name: Version and Release (DEPRECATED)
-# DEPRECATED β versioning and releases are now handled by build_nightly.yml.
+# DEPRECATED β versioning and releases are now handled by build_weekly.yml.
# This workflow is kept as workflow_dispatch only for manual overrides or
# emergency use. Do not re-enable the push trigger.
diff --git a/CLAUDE.md b/CLAUDE.md
index 16f7c5023..5a94835b2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ devicon/
2. `peek_icons.yml` runs Icomoon peek β `post_peek_screenshot.yml` posts screenshots as PR comment
3. Maintainer approves β `auto_build_merge.yml` squash-merges to master (**no build step**)
-### Weekly Build (`build_nightly.yml`)
+### Weekly Build (`build_weekly.yml`)
- Runs every Monday at 2 AM UTC (also via `workflow_dispatch`)
- Detects icon changes since last `build:` commit
- Runs Icomoon build β commits font files with `build:` prefix
@@ -103,4 +103,4 @@ Each entry looks like:
- `icomoon.json` is updated by the build script β don't edit manually.
- `devicon.min.css` is generated by `npm run build-css` β don't edit manually.
- The `build:` commit prefix is used by the weekly build to detect the last successful build.
-- `version_and_release.yml` is deprecated β versioning is handled by `build_nightly.yml`.
+- `version_and_release.yml` is deprecated β versioning is handled by `build_weekly.yml`.
From 7b75a6d7a7f3e935ccd0ac4125359f73f22e0ec8 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 15:37:49 +0200
Subject: [PATCH 23/29] feat: manual build/release modes in build_weekly.yml;
publish to npm from the release
- Add workflow_dispatch 'mode' input: build and release / build only /
release only. Replaces build_icons.yml and version_and_release.yml.
- Derive the version bump in the release job from PRs merged since the
last v* tag, so it also works for release-only runs.
- Release job checks out master explicitly so it includes the build:
commit pushed by the build job; build job rebases before pushing.
- Call npm_publish.yml as a reusable workflow: releases created with
GITHUB_TOKEN don't trigger 'release' events, so npm was never published.
- Serialize runs with a concurrency group.
- Update CONTRIBUTING release section and CLAUDE.md; add TODO for a local
(non-icomoon) font build.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/build_icons.yml | 95 -----------
.github/workflows/build_weekly.yml | 191 ++++++++++++++--------
.github/workflows/npm_publish.yml | 14 +-
.github/workflows/version_and_release.yml | 67 --------
CLAUDE.md | 27 ++-
CONTRIBUTING.md | 28 +---
6 files changed, 158 insertions(+), 264 deletions(-)
delete mode 100644 .github/workflows/build_icons.yml
delete mode 100644 .github/workflows/version_and_release.yml
diff --git a/.github/workflows/build_icons.yml b/.github/workflows/build_icons.yml
deleted file mode 100644
index e1584d6fe..000000000
--- a/.github/workflows/build_icons.yml
+++ /dev/null
@@ -1,95 +0,0 @@
-name: Build Icons (Emergency Fallback)
-
-# Manual emergency re-run for when the weekly build fails or needs to be
-# forced outside the regular schedule. Commits font files directly to master
-# with a "build:" prefix so build_weekly.yml doesn't double-process.
-#
-# For day-to-day releases, use build_weekly.yml instead.
-
-on:
- workflow_dispatch:
- inputs:
- reason:
- description: 'Reason for manual build (for audit trail)'
- required: false
- default: 'Manual emergency build'
-
-permissions:
- contents: write
-
-jobs:
- build:
- name: Emergency Build β Fonts + CSS
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout master
- uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- fetch-depth: 0
-
- - name: Setup Python 3.10
- uses: actions/setup-python@v5
- with:
- python-version: '3.10'
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r ./.github/scripts/requirements.txt
- npm install
-
- - name: Run Icomoon build
- id: build
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- python ./.github/scripts/icomoon_build.py \
- ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
- ./icomoon.json \
- ./devicon.json \
- ./icons \
- ./ \
- $GITHUB_TOKEN \
- --headless
-
- - name: Build devicon.min.css
- run: npm run build-css
-
- - name: Upload geckodriver.log on failure
- uses: actions/upload-artifact@v4
- if: failure()
- with:
- name: geckodriver-log
- path: ./geckodriver.log
-
- - name: Upload build log
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: logfile
- path: ./log.txt
-
- - name: Commit build artifacts directly to master
- run: |
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
- git add -A
- if ! git diff --staged --quiet; then
- git commit -m "build: emergency font rebuild
-
-Reason: ${{ inputs.reason }}
-
-Generated with [Claude Code](https://claude.com/claude-code)
-
-Co-Authored-By: Claude "
- git push
- else
- echo "No changes to commit"
- fi
diff --git a/.github/workflows/build_weekly.yml b/.github/workflows/build_weekly.yml
index 804f8c67e..2f1d62362 100644
--- a/.github/workflows/build_weekly.yml
+++ b/.github/workflows/build_weekly.yml
@@ -1,11 +1,25 @@
name: Weekly Build and Release
-# Runs every Monday at 2 AM UTC, or manually via workflow_dispatch
+# Runs every Monday at 2 AM UTC, or manually via workflow_dispatch.
+#
+# Manual runs can pick a mode:
+# build and release - same as the weekly run (default)
+# build only - build fonts + CSS and commit them, no release
+# release only - bump version and release what's on master, no build
on:
schedule:
- cron: '0 2 * * 1' # 2 AM UTC every Monday
workflow_dispatch:
inputs:
+ mode:
+ description: 'What to run'
+ required: false
+ default: 'build and release'
+ type: choice
+ options:
+ - build and release
+ - build only
+ - release only
bump:
description: 'Version bump level (auto = derive from PR labels)'
required: false
@@ -21,13 +35,19 @@ permissions:
contents: write
issues: write
+# Never run two builds/releases at once β they'd race on icomoon.json and tags
+concurrency:
+ group: build-and-release
+ cancel-in-progress: false
+
jobs:
build:
- name: Build and Stage Release
+ name: Build Fonts and CSS
+ # Scheduled runs have no inputs, so inputs.mode is empty and the build runs
+ if: inputs.mode != 'release only'
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check-changes.outputs.has_changes }}
- bump: ${{ steps.version-bump.outputs.bump }}
build_ok: ${{ steps.build-result.outputs.build_ok }}
steps:
@@ -67,70 +87,8 @@ jobs:
else
echo "Icon changes found since last build"
echo "has_changes=true" >> $GITHUB_OUTPUT
- echo "range=$RANGE" >> $GITHUB_OUTPUT
fi
- - name: Determine version bump level
- if: steps.check-changes.outputs.has_changes == 'true'
- id: version-bump
- uses: actions/github-script@v7
- with:
- script: |
- const manualBump = '${{ inputs.bump }}';
- if (manualBump && manualBump !== 'auto') {
- console.log('Manual bump override:', manualBump);
- core.setOutput('bump', manualBump);
- return;
- }
-
- const range = '${{ steps.check-changes.outputs.range }}';
- const { execSync } = require('child_process');
-
- // Get commit messages in range that touch icon files
- let log = '';
- try {
- log = execSync(`git log --format="%s" ${range} -- icons/ devicon.json`).toString().trim();
- } catch (e) {
- console.log('Could not get git log:', e.message);
- }
-
- if (!log) {
- console.log('No matching commits found, defaulting to patch');
- core.setOutput('bump', 'patch');
- return;
- }
-
- // Extract PR numbers from squash commit messages like "new icon: foo (#123)"
- const prNumbers = [...log.matchAll(/\(#(\d+)\)/g)].map(m => parseInt(m[1]));
- console.log('PR numbers found in commits:', prNumbers);
-
- let bump = 'patch';
-
- for (const prNumber of prNumbers) {
- try {
- const { data: pr } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber
- });
-
- const labels = pr.labels.map(l => l.name);
- console.log(`PR #${prNumber} labels:`, labels);
-
- if (labels.includes('release: major')) {
- bump = 'major';
- break; // Can't go higher
- } else if (labels.includes('release: minor') && bump !== 'major') {
- bump = 'minor';
- }
- } catch (e) {
- console.log(`Could not fetch PR #${prNumber}:`, e.message);
- }
- }
-
- console.log('Final version bump:', bump);
- core.setOutput('bump', bump);
-
- name: Setup Python 3.10
if: steps.check-changes.outputs.has_changes == 'true'
uses: actions/setup-python@v5
@@ -261,7 +219,9 @@ jobs:
git add -A
if ! git diff --staged --quiet; then
git commit -m "build: generate font files and CSS"
- git push
+ # master may have moved while the build ran
+ git pull --rebase origin master
+ git push origin HEAD:master
else
echo "No build artifacts changed"
fi
@@ -276,18 +236,100 @@ jobs:
release:
name: Bump Version and Release
needs: build
- # Only run when the build job found icon changes AND succeeded
- if: needs.build.outputs.has_changes == 'true' && needs.build.outputs.build_ok == 'true'
+ # build and release: only when the build produced new icons.
+ # release only: the build job is skipped, so don't depend on its result.
+ if: >
+ !cancelled() && (
+ inputs.mode == 'release only' ||
+ (inputs.mode != 'build only' &&
+ needs.build.result == 'success' &&
+ needs.build.outputs.build_ok == 'true')
+ )
runs-on: ubuntu-latest
environment: production
+ outputs:
+ version: ${{ steps.bump.outputs.version }}
steps:
- name: Checkout master
uses: actions/checkout@v4
with:
+ # Explicit ref so we get the build: commit pushed by the build job,
+ # not the commit the workflow was triggered on
+ ref: master
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
+ - name: Find commits since last release
+ id: range
+ run: |
+ LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
+ if [ -z "$LAST_TAG" ]; then
+ echo "No previous release tag β using full history"
+ RANGE="HEAD"
+ else
+ echo "Last release: $LAST_TAG"
+ RANGE="${LAST_TAG}..HEAD"
+ if [ "$(git rev-list --count "$RANGE")" = "0" ]; then
+ echo "::error::Nothing to release β no commits since $LAST_TAG"
+ exit 1
+ fi
+ fi
+ echo "range=$RANGE" >> $GITHUB_OUTPUT
+
+ - name: Determine version bump level
+ id: version-bump
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const manualBump = '${{ inputs.bump }}';
+ if (manualBump && manualBump !== 'auto') {
+ console.log('Manual bump override:', manualBump);
+ core.setOutput('bump', manualBump);
+ return;
+ }
+
+ const range = '${{ steps.range.outputs.range }}';
+ const { execSync } = require('child_process');
+
+ let log = '';
+ try {
+ log = execSync(`git log --format="%s" ${range}`).toString().trim();
+ } catch (e) {
+ console.log('Could not get git log:', e.message);
+ }
+
+ // Extract PR numbers from squash commit messages like "new icon: foo (#123)"
+ const prNumbers = [...new Set([...log.matchAll(/\(#(\d+)\)/g)].map(m => parseInt(m[1])))];
+ console.log('PR numbers found in commits:', prNumbers);
+
+ let bump = 'patch';
+
+ for (const prNumber of prNumbers) {
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber
+ });
+
+ const labels = pr.labels.map(l => l.name);
+ console.log(`PR #${prNumber} labels:`, labels);
+
+ if (labels.includes('release: major')) {
+ bump = 'major';
+ break; // Can't go higher
+ } else if (labels.includes('release: minor') && bump !== 'major') {
+ bump = 'minor';
+ }
+ } catch (e) {
+ console.log(`Could not fetch PR #${prNumber}:`, e.message);
+ }
+ }
+
+ console.log('Final version bump:', bump);
+ core.setOutput('bump', bump);
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -299,7 +341,7 @@ jobs:
- name: Bump version
id: bump
run: |
- BUMP="${{ needs.build.outputs.bump }}"
+ BUMP="${{ steps.version-bump.outputs.bump }}"
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
npm version $BUMP -m "chore: bump version to %s"
@@ -312,7 +354,7 @@ jobs:
with:
script: |
const version = '${{ steps.bump.outputs.version }}';
- const bump = '${{ needs.build.outputs.bump }}';
+ const bump = '${{ steps.version-bump.outputs.bump }}';
await github.rest.repos.createRelease({
owner: context.repo.owner,
@@ -324,3 +366,12 @@ jobs:
});
console.log(`Released v${version} (${bump} bump).`);
+
+ publish:
+ name: Publish to npm
+ needs: release
+ if: ${{ !cancelled() && needs.release.result == 'success' }}
+ uses: ./.github/workflows/npm_publish.yml
+ with:
+ ref: v${{ needs.release.outputs.version }}
+ secrets: inherit
diff --git a/.github/workflows/npm_publish.yml b/.github/workflows/npm_publish.yml
index 8cd03096d..5917bfcf0 100644
--- a/.github/workflows/npm_publish.yml
+++ b/.github/workflows/npm_publish.yml
@@ -1,7 +1,16 @@
name: "Publish to npm"
on:
+ # Releases created by hand in the GitHub UI
release:
types: [released]
+ # Releases created by build_weekly.yml. Those use GITHUB_TOKEN, which doesn't
+ # trigger other workflows, so build_weekly.yml calls this one directly.
+ workflow_call:
+ inputs:
+ ref:
+ description: 'Tag or branch to publish'
+ required: true
+ type: string
jobs:
publish:
environment: release
@@ -10,8 +19,9 @@ jobs:
- uses: actions/checkout@v4
with:
# "ref" specifies the branch to check out.
- # "github.event.release.target_commitish" is a global variable and specifies the branch the release targeted
- ref: ${{ github.event.release.target_commitish }}
+ # inputs.ref is set when called from build_weekly.yml; otherwise
+ # "github.event.release.target_commitish" is the branch the release targeted
+ ref: ${{ inputs.ref || github.event.release.target_commitish }}
- name: Use Node.js v24
uses: actions/setup-node@v4
with:
diff --git a/.github/workflows/version_and_release.yml b/.github/workflows/version_and_release.yml
deleted file mode 100644
index 391e93f5a..000000000
--- a/.github/workflows/version_and_release.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-name: Version and Release (DEPRECATED)
-
-# DEPRECATED β versioning and releases are now handled by build_weekly.yml.
-# This workflow is kept as workflow_dispatch only for manual overrides or
-# emergency use. Do not re-enable the push trigger.
-
-on:
- workflow_dispatch:
- inputs:
- bump:
- description: 'Version bump level'
- required: true
- default: 'patch'
- type: choice
- options:
- - patch
- - minor
- - major
-
-permissions:
- contents: write
-
-jobs:
- version-and-release:
- name: Manual Version Bump and Release
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout master
- uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
-
- - name: Configure git
- run: |
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
-
- - name: Bump version
- id: bump
- run: |
- BUMP="${{ inputs.bump }}"
- npm version $BUMP -m "chore: bump version to %s"
- VERSION=$(node -p "require('./package.json').version")
- echo "version=$VERSION" >> $GITHUB_OUTPUT
- git push --follow-tags
-
- - name: Create GitHub release
- uses: actions/github-script@v7
- with:
- script: |
- const version = '${{ steps.bump.outputs.version }}';
- const bump = '${{ inputs.bump }}';
- await github.rest.repos.createRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- tag_name: `v${version}`,
- name: `Release v${version}`,
- generate_release_notes: true
- });
- console.log(`Release v${version} created (manual ${bump} bump)`);
diff --git a/CLAUDE.md b/CLAUDE.md
index 5a94835b2..6aed5c29a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,12 +43,12 @@ devicon/
2. `peek_icons.yml` runs Icomoon peek β `post_peek_screenshot.yml` posts screenshots as PR comment
3. Maintainer approves β `auto_build_merge.yml` squash-merges to master (**no build step**)
-### Weekly Build (`build_weekly.yml`)
-- Runs every Monday at 2 AM UTC (also via `workflow_dispatch`)
-- Detects icon changes since last `build:` commit
-- Runs Icomoon build β commits font files with `build:` prefix
-- Determines semver bump from merged PR labels β release job (gated by `production` environment approval) bumps version β creates release
-- On failure: creates a GitHub issue
+### Weekly Build and Release (`build_weekly.yml`)
+- Runs every Monday at 2 AM UTC
+- `build` job: detects icon changes since last `build:` commit β runs Icomoon build + CSS β commits with `build:` prefix. On failure: creates a `build-failed` issue
+- `release` job (gated by `production` environment approval): derives semver bump from labels of PRs merged since the last `v*` tag β `npm version` β pushes tag β creates GitHub release
+- `publish` job: calls `npm_publish.yml` as a reusable workflow (releases made with `GITHUB_TOKEN` don't trigger `release:` events)
+- Only one run at a time (`concurrency: build-and-release`)
### Version Bump Labels
| PR Label | Bump |
@@ -57,8 +57,17 @@ devicon/
| `release: minor` | minor |
| (none) | patch |
-### Emergency Build
-Use `build_icons.yml` (workflow_dispatch) for manual re-runs. It commits directly to master β no PR created.
+### Manual Build / Release
+Run `build_weekly.yml` via workflow_dispatch and pick a `mode`:
+- `build and release` β same as the weekly run
+- `build only` β commit fonts + CSS to master, no release (e.g. re-run after a failed build)
+- `release only` β release what's on master without building (fails if there are no commits since the last tag)
+
+The `bump` input (`auto`/`patch`/`minor`/`major`) overrides the label-derived bump.
+
+## TODO
+- Replace icomoon.io (Selenium) with a local Node font build: keep existing glyphs from `icomoon.json`, normalize new SVGs to the 1024 grid, assign next codepoint, generate fonts with `svgicons2svgfont` β `svg2ttf` β `ttf2woff`/`ttf2eot`. Prototype first and diff glyph output against current fonts. Would also allow real-font PR previews instead of peek-bot.
+- Update wiki pages still describing the `develop` flow (Release-strategy, Creating-Pull-Request-for-New-Icon, Overview-on-Submitting-Icons, Requesting-an-Icon, About-the-Maintainers, Our-automated-tasks-and-bots) β on a wiki branch, publish together with the migration.
## Common Tasks
@@ -103,4 +112,4 @@ Each entry looks like:
- `icomoon.json` is updated by the build script β don't edit manually.
- `devicon.min.css` is generated by `npm run build-css` β don't edit manually.
- The `build:` commit prefix is used by the weekly build to detect the last successful build.
-- `version_and_release.yml` is deprecated β versioning is handled by `build_weekly.yml`.
+- All building, versioning and releasing goes through `build_weekly.yml` (the old `build_icons.yml` and `version_and_release.yml` were removed).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 772743dbe..3a19fc729 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -457,8 +457,7 @@ We are running a Discord server. You can go here to talk, discuss, and more with
Release strategy, conventions, preparation and execution
Release strategy
-Devicon does not follow a strict release plan. A new release is depended on current amount of contributions, required bugfixes/patches and will be discussed by the team of maintainers.
-Generally speaking: A new release will be published when new icons are added or a bug was fixed. When it's predictable that multiple icons are added in a foreseeable amount of time they are usually wrapped together.
+Devicon releases weekly. Every Monday the build_weekly.yml workflow builds the fonts for icons merged since the last build, and if anything new was built, a maintainer approves the release.
Conventions
The version naming follows the rules of Semantic Versioning. Given a version number MAJOR.MINOR.PATCH, increment the:
@@ -466,30 +465,17 @@ We are running a Discord server. You can go here to talk, discuss, and more with
- MINOR version when you add functionality (like a new icon) in a backwards compatible manner, and
- PATCH version when you make backwards compatible bug fixes.
+The bump is derived from the labels of the pull requests merged since the last release: release: major, release: minor, or patch if neither is present.
Release preparation and execution
+The weekly run handles everything automatically:
- - Define the next release version number based on the conventions
- - Checkout
master as draft-release branch: git checkout master && git checkout -b draft-release
- - Bump the package version using
npm version vMAJOR.MINOR.PATCH -m "bump npm version to vMAJOR.MINOR.PATCH"
- - Push the branch
draft-release
- - Manually trigger the workflow
build_icons.yml (which has a workflow_dispatch event trigger) and select the branch draft-release as target branch. This will build a font version of all icons using icomoon and automatically creates a pull request to merge the build result back into draft-release
- - Review, approve and merge the auto-created pull request
- - Create a pull request from
draft-release towards master. Mention the release number in the pull request title (like "Release vMAJOR.MINOR.PATCH").
-
- -
- Add information about all new icons, fixes, features and enhancements in the description of the pull request.
-
- -
- We have a script that will do this for you. Check the `build-bot`'s PR message in the previous step. There should be a section where it displays the features that have been added to the release. You can copy the markdown there and use it for the release message.
-
-
-
- - Since it was already reviewed, a maintainer can merge it (DON'T perform a squash-merge).
- - Create a new release using the format "Release vMAJOR.MINOR.PATCH" as tag and release title. Use the earlier created description as description of the release.
- - Publishing the release will trigger the npm_publish.yml workflow which will execute a
npm publish leading to an updated npm package (vMAJOR.MINOR.PATCH).
+ - The
build job builds the fonts and CSS with icomoon and commits them to master with a build: commit. If the build fails, an issue labelled build-failed is created.
+ - The
release job waits for a maintainer to approve the production environment, then bumps the version, pushes the vMAJOR.MINOR.PATCH tag and creates the GitHub release with generated release notes.
+ - The
publish job runs npm_publish.yml, which updates the npm package.
- If icons fail to appear on the website, clear the CDN cache.
+To build or release outside the weekly schedule, run build_weekly.yml manually and pick a mode: build and release, build only or release only. The bump input overrides the label-derived version bump.
Recommended resources and tools
From 9c3a24805461cf3aa0814ae02db8c14220e6cd8d Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 16:27:38 +0200
Subject: [PATCH 24/29] feat: use GitHub's native merge rules; push to master
with a bot app token
- Remove auto_build_merge.yml: on fork PRs pull_request_review only gets
a read-only token, so it couldn't merge. Merging is now governed by a
ruleset on master (1 approval, squash only).
- build_weekly.yml: push build/version commits with a GitHub App token
(vars.BOT_APP_ID, secrets.BOT_APP_PRIVATE_KEY) so it can bypass master
protection; falls back to GITHUB_TOKEN when not configured. Releases
are still created with GITHUB_TOKEN to avoid a double npm publish.
- npm_publish.yml: dry run outside devicons/devicon.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/auto_build_merge.yml | 101 -------------------------
.github/workflows/build_weekly.yml | 33 +++++++-
.github/workflows/npm_publish.yml | 3 +-
CLAUDE.md | 10 ++-
4 files changed, 41 insertions(+), 106 deletions(-)
delete mode 100644 .github/workflows/auto_build_merge.yml
diff --git a/.github/workflows/auto_build_merge.yml b/.github/workflows/auto_build_merge.yml
deleted file mode 100644
index 16a7ebb78..000000000
--- a/.github/workflows/auto_build_merge.yml
+++ /dev/null
@@ -1,101 +0,0 @@
-name: Auto Build and Merge
-
-on:
- pull_request_review:
- types: [submitted]
-
-permissions:
- contents: write
- pull-requests: write
- issues: write
-
-jobs:
- auto-merge:
- name: Auto Merge Icon PR
- runs-on: ubuntu-latest
- # Only act on approvals targeting master from the base repo (not forks)
- if: >
- github.event.review.state == 'approved' &&
- github.event.pull_request.base.ref == 'master'
-
- steps:
- - name: Check if PR modifies icon files
- id: check-files
- uses: actions/github-script@v7
- with:
- script: |
- const { data: files } = await github.rest.pulls.listFiles({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.payload.pull_request.number
- });
-
- const hasIconChanges = files.some(file =>
- file.filename.startsWith('icons/') || file.filename === 'devicon.json'
- );
-
- console.log('Has icon changes:', hasIconChanges);
- core.setOutput('has_icon_changes', String(hasIconChanges));
-
- if (!hasIconChanges) {
- console.log('PR does not modify icon files β skipping auto-merge');
- }
-
- - name: Check approval count
- if: steps.check-files.outputs.has_icon_changes == 'true'
- id: check-approvals
- uses: actions/github-script@v7
- with:
- script: |
- const { data: reviews } = await github.rest.pulls.listReviews({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.payload.pull_request.number
- });
-
- // Latest review per user wins
- const reviewsByUser = {};
- for (const review of reviews) {
- if (
- !reviewsByUser[review.user.login] ||
- new Date(review.submitted_at) > new Date(reviewsByUser[review.user.login].submitted_at)
- ) {
- reviewsByUser[review.user.login] = review;
- }
- }
-
- const approvals = Object.values(reviewsByUser).filter(r => r.state === 'APPROVED');
- console.log('Approval count:', approvals.length);
- core.setOutput('approval_count', String(approvals.length));
-
- - name: Squash merge PR
- if: >
- steps.check-files.outputs.has_icon_changes == 'true' &&
- steps.check-approvals.outputs.approval_count >= 1
- uses: actions/github-script@v7
- with:
- script: |
- const pr = context.payload.pull_request;
-
- try {
- await github.rest.pulls.merge({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: pr.number,
- merge_method: 'squash',
- commit_title: `${pr.title} (#${pr.number})`,
- commit_message: pr.body || ''
- });
- console.log(`PR #${pr.number} squash-merged successfully`);
- } catch (err) {
- console.error('Merge failed:', err.message);
- // Comment on PR to alert author and approver
- const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: pr.number,
- body: `@${pr.user.login} Auto-merge failed. Please check the [workflow run](${runUrl}) for details.`
- });
- core.setFailed('Merge failed: ' + err.message);
- }
diff --git a/.github/workflows/build_weekly.yml b/.github/workflows/build_weekly.yml
index 2f1d62362..973666617 100644
--- a/.github/workflows/build_weekly.yml
+++ b/.github/workflows/build_weekly.yml
@@ -51,10 +51,19 @@ jobs:
build_ok: ${{ steps.build-result.outputs.build_ok }}
steps:
+ # Token for pushing to master. See "Pushing to a protected master" below.
+ - name: Get bot token
+ id: bot-token
+ if: vars.BOT_APP_ID != ''
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ vars.BOT_APP_ID }}
+ private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
+
- name: Checkout master
uses: actions/checkout@v4
with:
- token: ${{ secrets.GITHUB_TOKEN }}
+ token: ${{ steps.bot-token.outputs.token || secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Check for new icon commits since last build
@@ -233,6 +242,15 @@ jobs:
# One-time setup (repo admin):
# Settings β Environments β New environment β name it "production"
# Add required reviewers (maintainers who should approve releases).
+ #
+ # ββ Pushing to a protected master ββββββββββββββββββββββββββββββββββββββββββββ
+ # The build and release jobs push commits/tags straight to master. When master
+ # is protected, GITHUB_TOKEN can't do that, so both jobs use a token from a
+ # GitHub App that is on the ruleset's bypass list:
+ # vars.BOT_APP_ID - the App ID
+ # secrets.BOT_APP_PRIVATE_KEY - a private key generated for the app
+ # The app only needs "Contents: Read and write". Without these set, the jobs
+ # fall back to GITHUB_TOKEN (fine for an unprotected master).
release:
name: Bump Version and Release
needs: build
@@ -251,13 +269,21 @@ jobs:
version: ${{ steps.bump.outputs.version }}
steps:
+ - name: Get bot token
+ id: bot-token
+ if: vars.BOT_APP_ID != ''
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ vars.BOT_APP_ID }}
+ private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
+
- name: Checkout master
uses: actions/checkout@v4
with:
# Explicit ref so we get the build: commit pushed by the build job,
# not the commit the workflow was triggered on
ref: master
- token: ${{ secrets.GITHUB_TOKEN }}
+ token: ${{ steps.bot-token.outputs.token || secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Find commits since last release
@@ -349,6 +375,9 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
git push --follow-tags
+ # Deliberately uses GITHUB_TOKEN (github-script's default), not the bot
+ # token: a release created with GITHUB_TOKEN doesn't trigger npm_publish.yml,
+ # so the publish job below is the only thing that publishes to npm.
- name: Create GitHub release
uses: actions/github-script@v7
with:
diff --git a/.github/workflows/npm_publish.yml b/.github/workflows/npm_publish.yml
index 5917bfcf0..9fa2f5858 100644
--- a/.github/workflows/npm_publish.yml
+++ b/.github/workflows/npm_publish.yml
@@ -28,6 +28,7 @@ jobs:
node-version: 24
registry-url: https://registry.npmjs.org/ # Specifies the registry, this field is required!
- run: npm ci
- - run: npm publish
+ # Forks only do a dry run so they can't publish the real package
+ - run: npm publish ${{ github.repository != 'devicons/devicon' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 6aed5c29a..4588bea6c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,7 +33,7 @@ devicon/
- **Single branch:** `master` only. No `develop` branch.
- Contributors open PRs against `master`.
-- Maintainers approve β auto-merge workflow fires.
+- Merging uses GitHub's native rules (ruleset on `master`): 1 approval required, squash merge only, squash commit title = PR title. No custom merge workflow.
- Font files are built weekly, not per PR.
## Workflow Architecture
@@ -41,7 +41,13 @@ devicon/
### PR Flow
1. Contributor opens PR β `check_icon_pr.yml` validates SVGs
2. `peek_icons.yml` runs Icomoon peek β `post_peek_screenshot.yml` posts screenshots as PR comment
-3. Maintainer approves β `auto_build_merge.yml` squash-merges to master (**no build step**)
+3. Maintainer approves β squash-merges (or GitHub auto-merge) to master, commit ` (#)` β the `(#n)` is what the release job reads labels from (**no build step**)
+
+### Repo setup the workflows depend on
+- Ruleset on `master`: require PR + 1 approval, squash only, block force-push/deletion. Bypass: the bot GitHub App (always).
+- Bot GitHub App with `Contents: Read and write`, installed on the repo; `vars.BOT_APP_ID` + `secrets.BOT_APP_PRIVATE_KEY`.
+- Environments: `production` (required reviewers, gates releases), `release` (holds `NPM_AUTOMATION_TOKEN`). Forks run `npm publish --dry-run`.
+- Labels: `release: major`, `release: minor`.
### Weekly Build and Release (`build_weekly.yml`)
- Runs every Monday at 2 AM UTC
From 45ffc484de817fed4eb444d4ef0968964da0d7cc Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 18:25:15 +0200
Subject: [PATCH 25/29] feat: rebuild updated icons and release whenever
something shipped changed
Build:
- Rebuild an icon's glyphs when its non-alias font versions in
devicon.json don't match icomoon.json, or one of its font SVGs changed
since the last build commit. Previously only icons missing from
icomoon.json were built, so 'update icon' PRs never reached the font.
- Match glyphs by exact icon name when removing them from icomoon.json.
Release:
- Release when icons, devicon.json, CSS or fonts changed since the last
tag, instead of only when icomoon.json changed. Decided in the build
job so weeks with nothing to ship don't request an approval.
- Remove the manual 'mode' input; a manual run does what the weekly run
does (reject the approval to build without releasing).
Co-Authored-By: Claude Opus 5.5
---
.github/scripts/build_assets/arg_getters.py | 6 +-
.github/scripts/icomoon_build_githubless.py | 73 +++++++++---
.github/workflows/build_weekly.yml | 125 +++++++++-----------
CLAUDE.md | 14 +--
CONTRIBUTING.md | 8 +-
5 files changed, 125 insertions(+), 101 deletions(-)
diff --git a/.github/scripts/build_assets/arg_getters.py b/.github/scripts/build_assets/arg_getters.py
index 299839e08..ccd3a2986 100644
--- a/.github/scripts/build_assets/arg_getters.py
+++ b/.github/scripts/build_assets/arg_getters.py
@@ -2,7 +2,7 @@
from build_assets.PathResolverAction import PathResolverAction
-def get_selenium_runner_args(has_token=True, peek_mode=False):
+def get_selenium_runner_args(has_token=True, peek_mode=False, build_since=False):
"""
Get the commandline arguments for the icomoon_peek.py and
icomoon_build.py.
@@ -39,6 +39,10 @@ def get_selenium_runner_args(has_token=True, peek_mode=False):
if has_token != False:
parser.add_argument("token",
help="The GitHub token to access the GitHub REST API.")
+ if build_since:
+ parser.add_argument("--since",
+ help="Commit to diff against to find updated SVGs (usually the last build commit)",
+ default=None)
return parser.parse_args()
diff --git a/.github/scripts/icomoon_build_githubless.py b/.github/scripts/icomoon_build_githubless.py
index 1a9ea9db5..192243284 100644
--- a/.github/scripts/icomoon_build_githubless.py
+++ b/.github/scripts/icomoon_build_githubless.py
@@ -19,10 +19,10 @@ def main():
runner = None
logfile = open("log.txt", "w")
try:
- args = arg_getters.get_selenium_runner_args(has_token=False)
- new_icons = get_icons_for_building(args.icomoon_json_path, args.devicon_json_path, logfile)
+ args = arg_getters.get_selenium_runner_args(has_token=False, build_since=True)
+ new_icons = get_icons_for_building(args.icomoon_json_path, args.devicon_json_path, args.since, logfile)
if len(new_icons) == 0:
- msg = "No new icons found in devicon.json that are not already in icomoon.json β nothing to build."
+ msg = "No new or changed font icons β nothing to build."
print(msg, file=logfile)
print(msg)
return
@@ -65,28 +65,66 @@ def main():
logfile.close()
-def get_icons_for_building(icomoon_json_path: str, devicon_json_path: str, logfile: FileIO):
+GLYPH_NAME_PATTERN = re.compile(r"^(.+)-((?:original|plain|line)(?:-wordmark)?)$")
+
+
+def get_icons_for_building(icomoon_json_path: str, devicon_json_path: str, since: str, logfile: FileIO):
"""
- Get the icons for building.
+ Get the icons whose font glyphs need to be (re)built. An icon is built when:
+ - its glyphs in `icomoon.json` don't match its font versions in `devicon.json`
+ (new icon, or font versions added/removed/renamed), or
+ - one of its font SVGs changed since the `since` commit (updated icon).
+ Font versions that are aliases are ignored, since the CSS provides those.
:param icomoon_json_path - the path to the `icomoon.json`.
:param devicon_json_path - the path to the `devicon.json`.
+ :param since - commit to diff against for changed SVGs. None to skip that check.
:param logfile.
- :return a list of dict containing info on the icons. These are
+ :return a list of dict containing info on the icons. These are
from the `devicon.json`.
"""
+ devicon_json = filehandler.get_json_file_content(devicon_json_path)
+ icomoon_json = filehandler.get_json_file_content(icomoon_json_path)
+
+ glyphs_by_icon = {}
+ for glyph in icomoon_json["icons"]:
+ match = GLYPH_NAME_PATTERN.match(glyph["properties"]["name"])
+ if match:
+ glyphs_by_icon.setdefault(match.group(1), set()).add(match.group(2))
+
+ changed_svgs = get_changed_svgs(since, logfile) if since else set()
- new_icons = []
+ icons_to_build = []
+ for icon in devicon_json:
+ aliases = {alias["alias"] for alias in icon["aliases"]}
+ expected = {v for v in icon["versions"]["font"] if v not in aliases}
+ if not expected:
+ continue
- # get any icons that might not have been found by the API
- # sometimes happen due to the PR being opened before the latest build release
- new_icons_from_devicon_json = filehandler.find_new_icons_in_devicon_json(
- devicon_json_path, icomoon_json_path)
+ actual = glyphs_by_icon.get(icon["name"], set())
+ if not actual:
+ reason = "new icon"
+ elif expected != actual:
+ reason = f"font versions changed (devicon.json: {sorted(expected)}, icomoon.json: {sorted(actual)})"
+ elif any(f"icons/{icon['name']}/{icon['name']}-{v}.svg" in changed_svgs for v in expected):
+ reason = "font SVG updated"
+ else:
+ continue
- for icon in new_icons_from_devicon_json:
- if icon not in new_icons:
- new_icons.append(icon)
+ print(f"{icon['name']}: {reason}", file=logfile)
+ icons_to_build.append(icon)
- return new_icons
+ return icons_to_build
+
+
+def get_changed_svgs(since: str, logfile: FileIO):
+ """
+ Get the SVG paths (relative to the repo root) changed between `since` and HEAD.
+ """
+ result = subprocess.run(["git", "diff", "--name-only", since, "HEAD", "--", "icons"],
+ capture_output=True, text=True, check=True)
+ changed = {path for path in result.stdout.splitlines() if path.endswith(".svg")}
+ print(f"{len(changed)} SVGs changed since {since}", file=logfile)
+ return changed
def optimize_svgs(new_icons: List[str], icons_folder_path: str, logfile: FileIO):
@@ -136,9 +174,10 @@ def find_icomoon_icon_not_in_new_icons(icomoon_icon: Dict, new_icons: List, mess
:param messages - an empty list where the function can attach logging on which
icon were removed.
"""
+ match = GLYPH_NAME_PATTERN.match(icomoon_icon["properties"]["name"])
for new_icon in new_icons:
- pattern = re.compile(f"^{new_icon['name']}-")
- if pattern.search(icomoon_icon["properties"]["name"]):
+ # exact name match, so rebuilding "foo" never removes "foo-bar" glyphs
+ if match and match.group(1) == new_icon["name"]:
message = f"-'{icomoon_icon['properties']['name']}' cause it matches '{new_icon['name']}'"
messages.append(message)
return False
diff --git a/.github/workflows/build_weekly.yml b/.github/workflows/build_weekly.yml
index 973666617..fede8621e 100644
--- a/.github/workflows/build_weekly.yml
+++ b/.github/workflows/build_weekly.yml
@@ -1,25 +1,18 @@
name: Weekly Build and Release
-# Runs every Monday at 2 AM UTC, or manually via workflow_dispatch.
+# Runs every Monday at 2 AM UTC, or manually via workflow_dispatch (same steps).
#
-# Manual runs can pick a mode:
-# build and release - same as the weekly run (default)
-# build only - build fonts + CSS and commit them, no release
-# release only - bump version and release what's on master, no build
+# build - (re)builds font glyphs for new/changed icons and the CSS, commits
+# them to master as "build: ...". Decides whether there's anything
+# to release.
+# release - waits for approval on the "production" environment, then bumps
+# the version, tags and creates the GitHub release.
+# publish - publishes the release to npm.
on:
schedule:
- cron: '0 2 * * 1' # 2 AM UTC every Monday
workflow_dispatch:
inputs:
- mode:
- description: 'What to run'
- required: false
- default: 'build and release'
- type: choice
- options:
- - build and release
- - build only
- - release only
bump:
description: 'Version bump level (auto = derive from PR labels)'
required: false
@@ -43,12 +36,9 @@ concurrency:
jobs:
build:
name: Build Fonts and CSS
- # Scheduled runs have no inputs, so inputs.mode is empty and the build runs
- if: inputs.mode != 'release only'
runs-on: ubuntu-latest
outputs:
- has_changes: ${{ steps.check-changes.outputs.has_changes }}
- build_ok: ${{ steps.build-result.outputs.build_ok }}
+ should_release: ${{ steps.release-check.outputs.should_release }}
steps:
# Token for pushing to master. See "Pushing to a protected master" below.
@@ -66,35 +56,27 @@ jobs:
token: ${{ steps.bot-token.outputs.token || secrets.GITHUB_TOKEN }}
fetch-depth: 0
- - name: Check for new icon commits since last build
+ - name: Check for icon changes since last build
id: check-changes
run: |
# Find the last commit whose message starts with "build:"
- LAST_BUILD=$(git log --format="%H %s" | awk '/^[a-f0-9]+ build:/ { print $1; exit }')
+ SINCE=$(git log --format="%H %s" | awk '/^[a-f0-9]+ build:/ { print $1; exit }')
- if [ -z "$LAST_BUILD" ]; then
+ if [ -z "$SINCE" ]; then
echo "No previous build commit found - falling back to last release tag"
- LAST_TAG=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -1)
- if [ -z "$LAST_TAG" ]; then
- echo "No release tag found either - checking all history"
- RANGE="$(git rev-list --max-parents=0 HEAD)..HEAD"
- else
- echo "Using last release tag as base: $LAST_TAG"
- RANGE="${LAST_TAG}..HEAD"
- fi
- else
- echo "Last build commit: $LAST_BUILD"
- RANGE="${LAST_BUILD}..HEAD"
+ SINCE=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
fi
+ if [ -z "$SINCE" ]; then
+ echo "No release tag found either - checking all history"
+ SINCE=$(git rev-list --max-parents=0 HEAD | tail -1)
+ fi
+ echo "Building changes since $SINCE"
+ echo "since=$SINCE" >> $GITHUB_OUTPUT
- # Check if any commits in range touch icons/ or devicon.json
- CHANGES=$(git diff --name-only $RANGE -- icons/ devicon.json 2>/dev/null)
-
- if [ -z "$CHANGES" ]; then
- echo "No icon changes since last build β skipping"
+ if git diff --quiet "$SINCE" HEAD -- icons/ devicon.json; then
+ echo "No icon changes since last build β skipping build"
echo "has_changes=false" >> $GITHUB_OUTPUT
else
- echo "Icon changes found since last build"
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
@@ -128,10 +110,15 @@ jobs:
if: steps.check-changes.outputs.has_changes == 'true'
run: chmod +x ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver
+ # Rebuilds icons whose glyphs don't match devicon.json (new icons, changed
+ # font versions) or whose font SVGs changed since the last build. Exits
+ # successfully without touching anything if there's nothing to build.
- name: Run Icomoon build
if: steps.check-changes.outputs.has_changes == 'true'
id: build
continue-on-error: true
+ env:
+ SINCE: ${{ steps.check-changes.outputs.since }}
run: |
python ./.github/scripts/icomoon_build_githubless.py \
./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver \
@@ -139,8 +126,11 @@ jobs:
./devicon.json \
./icons \
./ \
- --headless
+ --headless \
+ --since "$SINCE"
+ # Always rebuilt when icons/devicon.json changed: aliases and colors in
+ # devicon.json end up in devicon.min.css even when no glyphs change.
- name: Build CSS
if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success'
id: build-css
@@ -204,22 +194,6 @@ jobs:
core.setFailed('Weekly build failed - issue created');
- - name: Set build result output
- id: build-result
- if: always()
- run: |
- # build_ok=true only when the build + CSS succeeded AND icomoon.json was
- # actually modified β the script only touches icomoon.json when new icons
- # are processed, so this is a reliable "something was built" signal.
- if [[ "${{ steps.check-changes.outputs.has_changes }}" == "true" && \
- "${{ steps.build.outcome }}" == "success" && \
- "${{ steps.build-css.outcome }}" == "success" ]] && \
- ! git diff --quiet -- icomoon.json; then
- echo "build_ok=true" >> $GITHUB_OUTPUT
- else
- echo "build_ok=false" >> $GITHUB_OUTPUT
- fi
-
- name: Commit build artifacts
if: steps.check-changes.outputs.has_changes == 'true' && steps.build.outcome == 'success' && steps.build-css.outcome == 'success'
run: |
@@ -235,6 +209,27 @@ jobs:
echo "No build artifacts changed"
fi
+ # Release when anything users get (via npm / jsDelivr) changed since the
+ # last release tag: SVGs, devicon.json, CSS or fonts. Decided here rather
+ # than in the release job so weeks with nothing to ship don't ask for an
+ # approval. Skipped (= no release) if the build failed.
+ - name: Check for changes to release
+ id: release-check
+ run: |
+ SHIPPED="icons devicon.json devicon.min.css devicon-base.css fonts"
+ LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
+ if [ -z "$LAST_TAG" ]; then
+ echo "No previous release tag β releasing"
+ echo "should_release=true" >> $GITHUB_OUTPUT
+ elif git diff --quiet "$LAST_TAG" HEAD -- $SHIPPED; then
+ echo "Nothing to release since $LAST_TAG"
+ echo "should_release=false" >> $GITHUB_OUTPUT
+ else
+ echo "Changes to release since $LAST_TAG:"
+ git diff --stat "$LAST_TAG" HEAD -- $SHIPPED | tail -1
+ echo "should_release=true" >> $GITHUB_OUTPUT
+ fi
+
# ββ Release gate βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# This job requires a manual approval in the "production" GitHub Environment
# before bumping the version and publishing the release.
@@ -254,15 +249,7 @@ jobs:
release:
name: Bump Version and Release
needs: build
- # build and release: only when the build produced new icons.
- # release only: the build job is skipped, so don't depend on its result.
- if: >
- !cancelled() && (
- inputs.mode == 'release only' ||
- (inputs.mode != 'build only' &&
- needs.build.result == 'success' &&
- needs.build.outputs.build_ok == 'true')
- )
+ if: needs.build.outputs.should_release == 'true'
runs-on: ubuntu-latest
environment: production
outputs:
@@ -292,16 +279,11 @@ jobs:
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
if [ -z "$LAST_TAG" ]; then
echo "No previous release tag β using full history"
- RANGE="HEAD"
+ echo "range=HEAD" >> $GITHUB_OUTPUT
else
echo "Last release: $LAST_TAG"
- RANGE="${LAST_TAG}..HEAD"
- if [ "$(git rev-list --count "$RANGE")" = "0" ]; then
- echo "::error::Nothing to release β no commits since $LAST_TAG"
- exit 1
- fi
+ echo "range=${LAST_TAG}..HEAD" >> $GITHUB_OUTPUT
fi
- echo "range=$RANGE" >> $GITHUB_OUTPUT
- name: Determine version bump level
id: version-bump
@@ -399,7 +381,6 @@ jobs:
publish:
name: Publish to npm
needs: release
- if: ${{ !cancelled() && needs.release.result == 'success' }}
uses: ./.github/workflows/npm_publish.yml
with:
ref: v${{ needs.release.outputs.version }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 4588bea6c..214927909 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,8 +51,10 @@ devicon/
### Weekly Build and Release (`build_weekly.yml`)
- Runs every Monday at 2 AM UTC
-- `build` job: detects icon changes since last `build:` commit β runs Icomoon build + CSS β commits with `build:` prefix. On failure: creates a `build-failed` issue
-- `release` job (gated by `production` environment approval): derives semver bump from labels of PRs merged since the last `v*` tag β `npm version` β pushes tag β creates GitHub release
+- `build` job: if `icons/` or `devicon.json` changed since the last `build:` commit, runs the Icomoon build + CSS and commits with `build:` prefix. On failure: creates a `build-failed` issue
+ - An icon's glyphs are (re)built when its non-alias font versions in `devicon.json` don't match its glyphs in `icomoon.json` (new icon / versions changed), or one of its font SVGs changed since the last build (updated icon). Detection is by file diff, not PR titles.
+ - Ends by deciding `should_release`: anything shipped (`icons`, `devicon.json`, CSS, `fonts`) changed since the last `v*` tag
+- `release` job (only if `should_release`, gated by `production` environment approval): derives semver bump from labels of PRs merged since the last `v*` tag β `npm version` β pushes tag β creates GitHub release
- `publish` job: calls `npm_publish.yml` as a reusable workflow (releases made with `GITHUB_TOKEN` don't trigger `release:` events)
- Only one run at a time (`concurrency: build-and-release`)
@@ -64,12 +66,10 @@ devicon/
| (none) | patch |
### Manual Build / Release
-Run `build_weekly.yml` via workflow_dispatch and pick a `mode`:
-- `build and release` β same as the weekly run
-- `build only` β commit fonts + CSS to master, no release (e.g. re-run after a failed build)
-- `release only` β release what's on master without building (fails if there are no commits since the last tag)
+Run `build_weekly.yml` via workflow_dispatch β it does exactly what the weekly run does (build if needed, release if anything changed). To build without releasing, reject the `production` approval. The `bump` input (`auto`/`patch`/`minor`/`major`) overrides the label-derived bump.
-The `bump` input (`auto`/`patch`/`minor`/`major`) overrides the label-derived bump.
+### PR titles
+No longer used for building or releasing. Still used by `check_icon_pr.yml` (only runs for titles starting with `new icon`/`update icon`) and `peek_icons.yml` (finds the icon by name in the title), and they become the squash commit / release notes.
## TODO
- Replace icomoon.io (Selenium) with a local Node font build: keep existing glyphs from `icomoon.json`, normalize new SVGs to the 1024 grid, assign next codepoint, generate fonts with `svgicons2svgfont` β `svg2ttf` β `ttf2woff`/`ttf2eot`. Prototype first and diff glyph output against current fonts. Would also allow real-font PR previews instead of peek-bot.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3a19fc729..3a548b2f7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -457,7 +457,7 @@ We are running a Discord server. You can go here to talk, discuss, and more with
Release strategy, conventions, preparation and execution
Release strategy
-Devicon releases weekly. Every Monday the build_weekly.yml workflow builds the fonts for icons merged since the last build, and if anything new was built, a maintainer approves the release.
+Devicon releases weekly. Every Monday the build_weekly.yml workflow builds the fonts for icons added or changed since the last build, and if anything changed since the last release, a maintainer approves the release.
Conventions
The version naming follows the rules of Semantic Versioning. Given a version number MAJOR.MINOR.PATCH, increment the:
@@ -470,12 +470,12 @@ We are running a Discord server. You can go here to talk, discuss, and more with
Release preparation and execution
The weekly run handles everything automatically:
- - The
build job builds the fonts and CSS with icomoon and commits them to master with a build: commit. If the build fails, an issue labelled build-failed is created.
- - The
release job waits for a maintainer to approve the production environment, then bumps the version, pushes the vMAJOR.MINOR.PATCH tag and creates the GitHub release with generated release notes.
+ - The
build job builds the font glyphs of new and updated icons and the CSS with icomoon, and commits them to master with a build: commit. If the build fails, an issue labelled build-failed is created.
+ - If anything users receive (SVGs,
devicon.json, CSS or fonts) changed since the last release, the release job waits for a maintainer to approve the production environment, then bumps the version, pushes the vMAJOR.MINOR.PATCH tag and creates the GitHub release with generated release notes.
- The
publish job runs npm_publish.yml, which updates the npm package.
- If icons fail to appear on the website, clear the CDN cache.
-To build or release outside the weekly schedule, run build_weekly.yml manually and pick a mode: build and release, build only or release only. The bump input overrides the label-derived version bump.
+To build and release outside the weekly schedule, run build_weekly.yml manually; it does the same as the weekly run. The bump input overrides the label-derived version bump.
Recommended resources and tools
From 0a4cd3947b9d3371cbd6c22dc37c7715e4bbe9af Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 19:05:40 +0200
Subject: [PATCH 26/29] feat: find icons from changed files in check/peek bots;
enforce icon PR titles
check_icon_pr.yml and peek_icons.yml only worked out which icon a PR is
about from its title, and the check only ran for titles starting with
'new icon'/'update icon'. Icon PRs titled e.g. 'feat: add verilog icon'
skipped SVG validation and peek entirely.
- util.get_pr_icon_changes: diff the PR merge commit against HEAD^1 to
find changed icon SVGs and devicon.json entries.
- check_icon_pr.py: validate every touched icon. When SVGs change,
require one icon per PR and a title matching it: 'new icon:' vs
'update icon:' (from the target branch's devicon.json), the folder
name, and the added/changed SVG versions. Errors include the expected
title. devicon.json-only PRs have no title requirement.
- check_icon_pr.yml: run on every icon PR (no title gate), re-run when
the title is edited.
- icomoon_peek.py: peek the icon whose SVGs changed; skip quietly when
none did. post_peek_screenshot.yml posts nothing in that case.
Co-Authored-By: Claude Opus 5.5
---
.github/scripts/build_assets/arg_getters.py | 11 ++-
.github/scripts/build_assets/util.py | 92 ++++++++++++++++++++-
.github/scripts/check_icon_pr.py | 90 ++++++++++++--------
.github/scripts/icomoon_peek.py | 16 +++-
.github/workflows/check_icon_pr.yml | 6 ++
.github/workflows/peek_icons.yml | 8 +-
.github/workflows/post_peek_screenshot.yml | 16 ++--
CLAUDE.md | 12 ++-
package.json | 2 +-
9 files changed, 196 insertions(+), 57 deletions(-)
diff --git a/.github/scripts/build_assets/arg_getters.py b/.github/scripts/build_assets/arg_getters.py
index ccd3a2986..54944c346 100644
--- a/.github/scripts/build_assets/arg_getters.py
+++ b/.github/scripts/build_assets/arg_getters.py
@@ -34,8 +34,9 @@ def get_selenium_runner_args(has_token=True, peek_mode=False, build_since=False)
action=PathResolverAction)
if peek_mode:
- parser.add_argument("pr_title",
- help="The title of the PR that we are peeking at")
+ parser.add_argument("--base",
+ help="Commit to diff against to find the PR's changes (HEAD^1 in a PR merge commit)",
+ default="HEAD^1")
if has_token != False:
parser.add_argument("token",
help="The GitHub token to access the GitHub REST API.")
@@ -54,7 +55,7 @@ def get_check_icon_pr_args():
parser = ArgumentParser(description="Check the SVGs to ensure their attributes are correct. Run whenever a PR is opened")
parser.add_argument("pr_title",
- help="The title of the PR that we are peeking at")
+ help="The title of the PR that we are checking")
parser.add_argument("icons_folder_path",
help="The path to the icons folder",
@@ -64,6 +65,10 @@ def get_check_icon_pr_args():
help="The path to the devicon.json",
action=PathResolverAction)
+ parser.add_argument("--base",
+ help="Commit to diff against to find the PR's changes (HEAD^1 in a PR merge commit)",
+ default="HEAD^1")
+
return parser.parse_args()
diff --git a/.github/scripts/build_assets/util.py b/.github/scripts/build_assets/util.py
index e98b2a900..bf22ed1f1 100644
--- a/.github/scripts/build_assets/util.py
+++ b/.github/scripts/build_assets/util.py
@@ -1,10 +1,12 @@
import os
from pathlib import Path
import re
-from typing import List
+from typing import List, Tuple
import platform
import sys
import traceback
+import json
+import subprocess
from io import FileIO
def exit_with_err(err: Exception, logfile: FileIO=None):
@@ -72,6 +74,94 @@ def find_object_added_in_pr(icons: List[dict], pr_title: str):
raise Exception(message)
+def get_pr_icon_changes(devicon_json: List[dict], base: str="HEAD^1"):
+ """
+ Find out which icons a PR touches by diffing against `base`. In a PR's
+ merge commit (what actions/checkout checks out), HEAD^1 is the target branch.
+ :param devicon_json, the devicon.json content of the PR.
+ :param base, the commit to compare against.
+ :return a dict with:
+ "svgs": {icon folder name: [SVG file names added/modified/renamed]}
+ "json": names of devicon.json entries that were added or changed
+ "existing": names of devicon.json entries that already existed at `base`
+ """
+ diff = subprocess.run(
+ ["git", "diff", "--name-only", "--diff-filter=d", base, "HEAD", "--", "icons"],
+ capture_output=True, text=True, check=True).stdout.splitlines()
+ svgs = {}
+ for path in diff:
+ parts = path.split("/") # icons//.svg
+ if len(parts) == 3 and parts[2].endswith(".svg"):
+ svgs.setdefault(parts[1], []).append(parts[2])
+
+ try:
+ base_json = json.loads(subprocess.run(["git", "show", f"{base}:devicon.json"],
+ capture_output=True, text=True, check=True).stdout)
+ except subprocess.CalledProcessError:
+ base_json = []
+ base_icons = {icon["name"]: icon for icon in base_json}
+ changed_json = [icon["name"] for icon in devicon_json if base_icons.get(icon["name"]) != icon]
+
+ return {"svgs": svgs, "json": changed_json, "existing": set(base_icons)}
+
+
+def get_svg_versions(icon: dict, svg_files: List[str]) -> Tuple[List[str], List[str]]:
+ """
+ Get the versions of the given SVG files of an icon.
+ :param icon, the icon's devicon.json object.
+ :param svg_files, SVG file names in the icon's folder.
+ :return (versions, invalid file names). Versions are ordered like
+ versions.svg in devicon.json.
+ """
+ versions, invalid = [], []
+ for file in svg_files:
+ stem = Path(file).stem
+ version = stem[len(icon["name"]) + 1:]
+ if stem.startswith(icon["name"] + "-") and is_svg_version_valid(version):
+ versions.append(version)
+ else:
+ invalid.append(file)
+
+ order = icon["versions"]["svg"]
+ versions.sort(key=lambda v: (order.index(v) if v in order else len(order), v))
+ return versions, invalid
+
+
+pr_title_pattern = re.compile(r"^(new|update) icon:\s*([\w.-]+)\s*\(([^)]*)\)\s*$", re.I)
+def check_pr_title(pr_title: str, icon: dict, is_new: bool, versions: List[str]):
+ """
+ Check that an icon PR's title is `new icon: ()` or
+ `update icon: ()`, matching what the PR actually does.
+ :param pr_title, the PR title.
+ :param icon, the devicon.json object of the icon whose SVGs the PR changes.
+ :param is_new, whether the icon is new (not in the target branch's devicon.json).
+ :param versions, the SVG versions the PR adds/changes.
+ :return a string with the problems and the expected title, or "" if it's fine.
+ """
+ kind = "new" if is_new else "update"
+ expected = f"{kind} icon: {icon['name']} ({', '.join(versions)})"
+
+ problems = []
+ match = pr_title_pattern.match(pr_title.strip())
+ if not match:
+ problems.append("it doesn't follow the format `new icon: ()` or `update icon: ()`")
+ else:
+ title_kind, title_name = match.group(1).lower(), match.group(2).lower()
+ title_versions = {v.strip().lower() for v in match.group(3).split(",") if v.strip()}
+ if title_kind != kind:
+ what = "adds a new icon" if is_new else "updates an existing icon"
+ problems.append(f"this PR {what}, so the title should start with `{kind} icon:`")
+ if title_name != icon["name"]:
+ problems.append(f"the icon name should be `{icon['name']}` (as in the folder name and `devicon.json`), not `{title_name}`")
+ if title_versions != set(versions):
+ verb = "adds" if is_new else "changes"
+ problems.append(f"the versions should be the SVGs this PR {verb}: {', '.join(versions)}")
+
+ if problems:
+ return "PR title issues:\n" + "\n".join(f"- {p}" for p in problems) + f"\n\nExpected title:\n{expected}"
+ return ""
+
+
def is_svg_in_font_attribute(svg_file_path: Path, devicon_object: dict):
"""
Check if svg is in devicon.json's font attribute.
diff --git a/.github/scripts/check_icon_pr.py b/.github/scripts/check_icon_pr.py
index dc5eec771..1eb8cf8c7 100644
--- a/.github/scripts/check_icon_pr.py
+++ b/.github/scripts/check_icon_pr.py
@@ -10,49 +10,67 @@
def main():
"""
- Check the quality of the svgs IF this is an icon PR. Else, does nothing.
- If any svg error is found, create a json file called 'svg_err_messages.json'
- in the root folder that will contains the error messages.
+ Check the icons touched by this PR, found from the changed files: the
+ `devicon.json` entries and the SVGs. If the PR changes SVGs, also check that
+ it covers a single icon and that the PR title describes it.
+ Error messages are written to './err_messages.txt' (empty if all is well).
"""
args = arg_getters.get_check_icon_pr_args()
try:
all_icons = filehandler.get_json_file_content(args.devicon_json_path)
+ icons_by_name = {icon["name"]: icon for icon in all_icons}
+ changes = util.get_pr_icon_changes(all_icons, args.base)
+ print("Changed SVGs:", changes["svgs"])
+ print("Changed devicon.json entries:", changes["json"])
- devicon_err_msg = []
+ err_msg = []
#First check if devicon.json is sorted
if sorted(all_icons, key=lambda d: d['name']) != all_icons:
- devicon_err_msg.append(f"devicon.json is not sorted correctly.\nPlease make sure that your icon is added in the `devicon.json` file at the correct alphabetic position\nas seen here: https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60")
-
- # get only the icon object that has the name matching the pr title
- filtered_icon = util.find_object_added_in_pr(all_icons, args.pr_title)
- print("Checking devicon.json object: " + str(filtered_icon))
- devicon_err_msg.append(check_devicon_object(filtered_icon))
-
- # check the file names
- filename_err_msg = ""
- svgs = None
- try:
- svgs = filehandler.get_svgs_paths([filtered_icon], args.icons_folder_path, as_str=False)
- print("SVGs to check: ", *svgs, sep='\n')
- except ValueError as e:
- filename_err_msg = "Error found regarding filenames:\n- " + e.args[0]
-
- # check the svgs
- if svgs is None or len(svgs) == 0:
- print("No SVGs to check, ending script.")
- svg_err_msg = "Error checking SVGs: no SVGs to check. Might be caused by above issues."
- else:
- svg_err_msg = check_svgs(svgs, filtered_icon)
-
- err_msg = []
- if devicon_err_msg != []:
- err_msg.extend(devicon_err_msg)
-
- if filename_err_msg != "":
- err_msg.append(filename_err_msg)
-
- if svg_err_msg != "":
- err_msg.append(svg_err_msg)
+ err_msg.append(f"devicon.json is not sorted correctly.\nPlease make sure that your icon is added in the `devicon.json` file at the correct alphabetic position\nas seen here: https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60")
+
+ # An icon PR changes the SVGs of exactly one icon, and its title says which
+ svg_icons = sorted(changes["svgs"])
+ if len(svg_icons) > 1:
+ err_msg.append(f"This PR changes the SVGs of several icons ({', '.join(svg_icons)}).\nPlease open a separate PR for each icon.")
+ for name in svg_icons:
+ if name not in icons_by_name:
+ err_msg.append(f"`icons/{name}/` was changed, but `devicon.json` has no entry named \"{name}\".")
+ if len(svg_icons) == 1 and svg_icons[0] in icons_by_name:
+ name = svg_icons[0]
+ icon = icons_by_name[name]
+ versions, invalid_files = util.get_svg_versions(icon, changes["svgs"][name])
+ if invalid_files:
+ err_msg.append("These SVG file names don't match our pattern of `-(original|plain|line)(-wordmark)?.svg`:\n" +
+ "\n".join(f"- icons/{name}/{file}" for file in invalid_files))
+ if versions:
+ title_err = util.check_pr_title(args.pr_title, icon, name not in changes["existing"], versions)
+ if title_err:
+ err_msg.append(title_err)
+
+ # Check every icon this PR touches
+ to_check = sorted(set(svg_icons) | set(changes["json"]))
+ for name in to_check:
+ if name not in icons_by_name:
+ continue
+ icon = icons_by_name[name]
+ print("Checking devicon.json object: " + str(icon))
+ devicon_err = check_devicon_object(icon)
+ if devicon_err:
+ err_msg.append(devicon_err)
+
+ try:
+ svgs = filehandler.get_svgs_paths([icon], args.icons_folder_path, as_str=False)
+ print("SVGs to check: ", *svgs, sep='\n')
+ except ValueError as e:
+ err_msg.append(f"Error found regarding filenames of \"{name}\":\n- " + e.args[0])
+ continue
+
+ if len(svgs) == 0:
+ err_msg.append(f"Error checking SVGs of \"{name}\": no SVGs to check. Might be caused by above issues.")
+ else:
+ svg_err = check_svgs(svgs, icon)
+ if svg_err:
+ err_msg.append(svg_err)
filehandler.write_to_file("./err_messages.txt", "\n\n".join(err_msg))
print("Task completed.")
diff --git a/.github/scripts/icomoon_peek.py b/.github/scripts/icomoon_peek.py
index 7d66d1117..34436cdce 100644
--- a/.github/scripts/icomoon_peek.py
+++ b/.github/scripts/icomoon_peek.py
@@ -8,8 +8,20 @@ def main():
args = arg_getters.get_selenium_runner_args(has_token=False, peek_mode=True)
all_icons = filehandler.get_json_file_content(args.devicon_json_path)
- # get only the icon object that has the name matching the pr title
- filtered_icon = util.find_object_added_in_pr(all_icons, args.pr_title)
+ # peek at the icon whose SVGs this PR adds/changes
+ changes = util.get_pr_icon_changes(all_icons, args.base)
+ svg_icons = sorted(changes["svgs"])
+ if len(svg_icons) == 0:
+ # e.g. a devicon.json-only change: nothing to peek, no screenshots
+ print("No icon SVGs changed in this PR, nothing to peek.")
+ filehandler.write_to_file("./err_messages.txt", "")
+ return
+ if len(svg_icons) > 1:
+ raise Exception(f"This PR changes the SVGs of several icons ({', '.join(svg_icons)}). Please open a separate PR for each icon.")
+ filtered_icon = next((icon for icon in all_icons if icon["name"] == svg_icons[0]), None)
+ if filtered_icon is None:
+ raise Exception(f"`icons/{svg_icons[0]}/` was changed, but `devicon.json` has no entry named \"{svg_icons[0]}\".")
+
svgs = filehandler.get_svgs_paths([filtered_icon], args.icons_folder_path, True)
screenshot_folder = filehandler.create_screenshot_folder("./")
diff --git a/.github/workflows/check_icon_pr.yml b/.github/workflows/check_icon_pr.yml
index 9ce102d62..6ecbcde42 100644
--- a/.github/workflows/check_icon_pr.yml
+++ b/.github/workflows/check_icon_pr.yml
@@ -1,6 +1,8 @@
name: Check Icon PR
on:
pull_request:
+ # edited: re-check when the PR title is fixed
+ types: [opened, synchronize, reopened, edited]
paths:
- 'icons/**'
- 'devicon.json'
@@ -9,7 +11,11 @@ jobs:
name: Check the `devicon.json` and the SVGs' quality
runs-on: ubuntu-latest
steps:
+ # Checks out the PR's merge commit; depth 2 so HEAD^1 (the target branch)
+ # is available to diff against
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
- name: Check if PR targets master
if: ${{ github.base_ref != 'master' }}
diff --git a/.github/workflows/peek_icons.yml b/.github/workflows/peek_icons.yml
index 495222432..591e98016 100644
--- a/.github/workflows/peek_icons.yml
+++ b/.github/workflows/peek_icons.yml
@@ -13,7 +13,11 @@ jobs:
name: Peek Icons
runs-on: ubuntu-latest
steps:
+ # Checks out the PR's merge commit; depth 2 so HEAD^1 (the target branch)
+ # is available to diff against
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
- name: Setup Python v3.8
uses: actions/setup-python@v5
@@ -38,13 +42,11 @@ jobs:
path: ./pr_num.txt
- name: Run icomoon_peek.py
- env:
- PR_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: >
python ./.github/scripts/icomoon_peek.py
./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver ./icomoon.json
- ./devicon.json ./icons ./ --headless "$PR_TITLE"
+ ./devicon.json ./icons ./ --headless
- name: Upload the err messages (created by icomoon_peek.py)
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/post_peek_screenshot.yml b/.github/workflows/post_peek_screenshot.yml
index a0ac56201..4d1463621 100644
--- a/.github/workflows/post_peek_screenshot.yml
+++ b/.github/workflows/post_peek_screenshot.yml
@@ -60,14 +60,14 @@ jobs:
const branch = 'screenshots';
const screenshotsDir = './screenshots';
- if (!fs.existsSync(screenshotsDir)) {
- core.setFailed('Screenshots directory not found');
- return;
- }
-
- const files = fs.readdirSync(screenshotsDir).filter(f => f.endsWith('.png'));
+ // A successful peek without screenshots means the PR changed no icon
+ // SVGs (e.g. a devicon.json-only change), so there's nothing to post
+ const files = fs.existsSync(screenshotsDir)
+ ? fs.readdirSync(screenshotsDir).filter(f => f.endsWith('.png'))
+ : [];
if (files.length === 0) {
- core.setFailed('No screenshots found');
+ console.log('No screenshots β nothing to post');
+ core.setOutput('skipped', 'true');
return;
}
@@ -135,7 +135,7 @@ jobs:
console.log(`Uploaded ${files.length} screenshots to ${baseUrl}`);
- name: Comment on the PR - Success
- if: env.PEEK_STATUS == 'success' && success()
+ if: env.PEEK_STATUS == 'success' && success() && steps.upload_screenshots.outputs.skipped != 'true'
uses: actions/github-script@v7
env:
BASE_URL: ${{ steps.upload_screenshots.outputs.base_url }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 214927909..f468d6a17 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,8 +39,8 @@ devicon/
## Workflow Architecture
### PR Flow
-1. Contributor opens PR β `check_icon_pr.yml` validates SVGs
-2. `peek_icons.yml` runs Icomoon peek β `post_peek_screenshot.yml` posts screenshots as PR comment
+1. Contributor opens PR β `check_icon_pr.yml` validates the touched icons (found from changed files via `util.get_pr_icon_changes`, diffing the PR merge commit against `HEAD^1`) and the PR title β `post_check_icon_pr_comment.yml` comments any errors
+2. `peek_icons.yml` runs Icomoon peek on the icon whose SVGs changed β `post_peek_screenshot.yml` posts screenshots as PR comment (nothing posted for PRs without SVG changes)
3. Maintainer approves β squash-merges (or GitHub auto-merge) to master, commit ` (#)` β the `(#n)` is what the release job reads labels from (**no build step**)
### Repo setup the workflows depend on
@@ -69,7 +69,13 @@ devicon/
Run `build_weekly.yml` via workflow_dispatch β it does exactly what the weekly run does (build if needed, release if anything changed). To build without releasing, reject the `production` approval. The `bump` input (`auto`/`patch`/`minor`/`major`) overrides the label-derived bump.
### PR titles
-No longer used for building or releasing. Still used by `check_icon_pr.yml` (only runs for titles starting with `new icon`/`update icon`) and `peek_icons.yml` (finds the icon by name in the title), and they become the squash commit / release notes.
+Not used to find icons anywhere (bots, build, release all use changed files). Enforced by `check_icon_pr.yml` for PRs that change SVGs:
+- one icon per PR
+- title `new icon: ()` (icon not in target branch's `devicon.json`) or `update icon: ()`; versions = the SVGs the PR adds/changes; case and version order don't matter
+- on mismatch the check-bot comments the problems plus the expected title; editing the title re-runs the check
+- `devicon.json`-only PRs (aliases, tagsβ¦) have no title requirement
+
+The title becomes the squash commit and the release-notes line.
## TODO
- Replace icomoon.io (Selenium) with a local Node font build: keep existing glyphs from `icomoon.json`, normalize new SVGs to the 1024 grid, assign next codepoint, generate fonts with `svgicons2svgfont` β `svg2ttf` β `ttf2woff`/`ttf2eot`. Prototype first and diff glyph output against current fonts. Would also allow real-font PR previews instead of peek-bot.
diff --git a/package.json b/package.json
index 69089ae32..4ce2bd052 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"main": "devicon.min.css",
"scripts": {
"build-css": "gulp updateCss && gulp clean",
- "peek-test": "python ./.github/scripts/icomoon_peek.py ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver ./icomoon.json ./devicon.json ./icons ./ --pr_title \"$PR_TITLE\"",
+ "peek-test": "python ./.github/scripts/icomoon_peek.py ./.github/scripts/build_assets/geckodriver-v0.32.2-linux64/geckodriver ./icomoon.json ./devicon.json ./icons ./ --headless",
"optimize-svg": "gulp optimizeSvg",
"bump": "gulp bumpVersion",
"build-icons": "run-script-os",
From d57de59e547fc2e14f7ddaf7fe5c9a185700575e Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 19:31:29 +0200
Subject: [PATCH 27/29] fix: pin runners to ubuntu-24.04, Python 3.10 and
selenium 4.1.0
- ubuntu-latest is now Ubuntu 26.04, which has no Python 3.8 build, so
the check and peek jobs failed in setup-python. Pin the Python jobs to
ubuntu-24.04 (as upstream develop did in #2769).
- requests 2.34.2 needs Python >= 3.10: move check/peek from 3.8 to 3.10
(the weekly build already uses 3.10).
- selenium 4.51.0 (from #2766) removed the find_element_by_* API the
Selenium runners use; go back to 4.1.0, which earlier builds used.
Co-Authored-By: Claude Opus 5.5
---
.github/scripts/requirements.txt | 2 +-
.github/workflows/build_weekly.yml | 4 ++--
.github/workflows/check_icon_pr.yml | 4 ++--
.github/workflows/peek_icons.yml | 6 +++---
4 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt
index 438bce089..dc367f599 100644
--- a/.github/scripts/requirements.txt
+++ b/.github/scripts/requirements.txt
@@ -1,2 +1,2 @@
-selenium==4.51.0
+selenium==4.1.0
requests==2.34.2
diff --git a/.github/workflows/build_weekly.yml b/.github/workflows/build_weekly.yml
index fede8621e..efc2e6bde 100644
--- a/.github/workflows/build_weekly.yml
+++ b/.github/workflows/build_weekly.yml
@@ -36,7 +36,7 @@ concurrency:
jobs:
build:
name: Build Fonts and CSS
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
outputs:
should_release: ${{ steps.release-check.outputs.should_release }}
@@ -250,7 +250,7 @@ jobs:
name: Bump Version and Release
needs: build
if: needs.build.outputs.should_release == 'true'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
environment: production
outputs:
version: ${{ steps.bump.outputs.version }}
diff --git a/.github/workflows/check_icon_pr.yml b/.github/workflows/check_icon_pr.yml
index 6ecbcde42..5d636bf26 100644
--- a/.github/workflows/check_icon_pr.yml
+++ b/.github/workflows/check_icon_pr.yml
@@ -9,7 +9,7 @@ on:
jobs:
check:
name: Check the `devicon.json` and the SVGs' quality
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
# Checks out the PR's merge commit; depth 2 so HEAD^1 (the target branch)
# is available to diff against
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-python@v5
with:
- python-version: 3.8
+ python-version: '3.10'
- name: Install dependencies
run: |
diff --git a/.github/workflows/peek_icons.yml b/.github/workflows/peek_icons.yml
index 591e98016..2b84b5a55 100644
--- a/.github/workflows/peek_icons.yml
+++ b/.github/workflows/peek_icons.yml
@@ -11,7 +11,7 @@ jobs:
# unsuccessful check (fail due to user),
# fail due to system, skipped
name: Peek Icons
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
# Checks out the PR's merge commit; depth 2 so HEAD^1 (the target branch)
# is available to diff against
@@ -19,10 +19,10 @@ jobs:
with:
fetch-depth: 2
- - name: Setup Python v3.8
+ - name: Setup Python v3.10
uses: actions/setup-python@v5
with:
- python-version: 3.8
+ python-version: '3.10'
- name: Install dependencies
run: |
From 6492209e2af0c4ec3a133f87b359414379d0bdc8 Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 19:37:02 +0200
Subject: [PATCH 28/29] fix: fail the icon check when it finds issues
The check job succeeded even when it found problems (e.g. a wrong PR
title) and only the comment reported them, so the PR showed a green check.
- check_icon_pr.yml: upload the PR number first and the error file even
if the script crashed, then fail the job when there are errors.
- post_check_icon_pr_comment.yml: post the issues comment when the check
failed too (it used to bail out); the 'Failure' comment remains for
runs that produced no artifacts. Mention that editing the title
re-runs the check.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/check_icon_pr.yml | 37 ++++++++++++-------
.../workflows/post_check_icon_pr_comment.yml | 10 ++---
2 files changed, 29 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/check_icon_pr.yml b/.github/workflows/check_icon_pr.yml
index 5d636bf26..cdded39ae 100644
--- a/.github/workflows/check_icon_pr.yml
+++ b/.github/workflows/check_icon_pr.yml
@@ -33,28 +33,39 @@ jobs:
python -m pip install --upgrade pip
pip install -r ./.github/scripts/requirements.txt
+ - name: Save the pr num in an artifact
+ shell: bash
+ env:
+ PR_NUM: ${{ github.event.number }}
+ run: echo $PR_NUM > pr_num.txt
+
+ - name: Upload the pr num
+ uses: actions/upload-artifact@v4
+ with:
+ name: pr_num
+ path: ./pr_num.txt
+
- name: Run the check_svg script
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: python ./.github/scripts/check_icon_pr.py "$PR_TITLE" ./icons ./devicon.json
+ # Uploaded even if the script crashed: it writes the error to this file
- name: Upload the err messages
uses: actions/upload-artifact@v4
- if: success()
+ if: always()
with:
name: err_messages
path: ./err_messages.txt
+ if-no-files-found: ignore
- - name: Save the pr num in an artifact
+ # Fail the check when issues were found, so the PR shows a red check.
+ # post_check_icon_pr_comment.yml posts the details as a comment.
+ - name: Fail if issues were found
shell: bash
- if: success()
- env:
- PR_NUM: ${{ github.event.number }}
- run: echo $PR_NUM > pr_num.txt
-
- - name: Upload the pr num
- uses: actions/upload-artifact@v4
- if: success()
- with:
- name: pr_num
- path: ./pr_num.txt
+ run: |
+ if [ -s err_messages.txt ]; then
+ echo "::error::Issues found in this PR (details in the check-bot comment):"
+ cat err_messages.txt
+ exit 1
+ fi
diff --git a/.github/workflows/post_check_icon_pr_comment.yml b/.github/workflows/post_check_icon_pr_comment.yml
index 681fa8e12..6951293d6 100644
--- a/.github/workflows/post_check_icon_pr_comment.yml
+++ b/.github/workflows/post_check_icon_pr_comment.yml
@@ -8,11 +8,11 @@ jobs:
post_result_of_svg_check:
name: Post the result of the Check SVG Action
runs-on: ubuntu-latest
+ # The check fails when it finds issues, so post on failure too. If the check
+ # couldn't produce its artifacts at all, the steps below fail and the
+ # "Failure" comment is posted instead.
+ if: github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure'
steps:
- - name: Check if the trigger run worked. If it failed, fail the current run.
- if: github.event.workflow_run.conclusion != 'success'
- uses: cutenode/action-always-fail@v1.0.1
-
- name: Download workflow artifact
uses: dawidd6/action-download-artifact@v7
if: success()
@@ -50,7 +50,7 @@ jobs:
Check our [CONTRIBUTING guide](https://github.com/devicons/devicon/wiki/SVG-Standards) for more details regarding these errors.
- Please address these issues. When you update this PR, I will check your SVGs again.
+ Please address these issues. When you push new commits or edit the PR title, I will check your PR again.
Thanks for your help,
SVG-Checker Bot :smile:
From 4e3d9be280331acb2ae0878971b4610fda23cfab Mon Sep 17 00:00:00 2001
From: Snailedlt
Date: Sat, 24 Oct 2026 19:45:40 +0200
Subject: [PATCH 29/29] feat: auto-rename icon PRs; icon PRs may only touch
their own icon
check_icon_pr.py:
- Icon PRs (any change in icons/) may only change one icons//
folder and that icon's devicon.json entry. Other added/modified/deleted
files or other entries fail the check.
- When the title is wrong but the PR's scope is clean, write the expected
title to expected_title.txt instead of reporting an error.
post_check_icon_pr_comment.yml (rewritten with github-script):
- Rename the PR to the expected title with the bot app token, which
re-runs the check, and comment old -> new. Without the bot app, comment
the expected title.
- Only act on the PR if its head is the commit that was checked, since
artifacts from fork runs are untrusted; only accept icon-format titles.
Co-Authored-By: Claude Opus 5.5
---
.github/scripts/build_assets/util.py | 43 +++--
.github/scripts/check_icon_pr.py | 65 ++++---
.github/workflows/check_icon_pr.yml | 20 ++-
.../workflows/post_check_icon_pr_comment.yml | 169 +++++++++++++-----
CLAUDE.md | 12 +-
5 files changed, 221 insertions(+), 88 deletions(-)
diff --git a/.github/scripts/build_assets/util.py b/.github/scripts/build_assets/util.py
index bf22ed1f1..ef5debc9a 100644
--- a/.github/scripts/build_assets/util.py
+++ b/.github/scripts/build_assets/util.py
@@ -81,18 +81,29 @@ def get_pr_icon_changes(devicon_json: List[dict], base: str="HEAD^1"):
:param devicon_json, the devicon.json content of the PR.
:param base, the commit to compare against.
:return a dict with:
- "svgs": {icon folder name: [SVG file names added/modified/renamed]}
- "json": names of devicon.json entries that were added or changed
+ "svgs": {icon folder name: [SVG file names added/modified]}
+ "icon_folders": sorted names of the icons// folders with any change
+ (added, modified or deleted files)
+ "other_files": changed paths outside icons// and devicon.json
+ "json": names of devicon.json entries that were added, changed or removed
"existing": names of devicon.json entries that already existed at `base`
"""
- diff = subprocess.run(
- ["git", "diff", "--name-only", "--diff-filter=d", base, "HEAD", "--", "icons"],
- capture_output=True, text=True, check=True).stdout.splitlines()
- svgs = {}
- for path in diff:
- parts = path.split("/") # icons//.svg
- if len(parts) == 3 and parts[2].endswith(".svg"):
- svgs.setdefault(parts[1], []).append(parts[2])
+ # -z: NUL-separated "status, path" pairs, so unusual file names parse safely.
+ # --no-renames: a rename shows up as a delete + add, so both paths count.
+ out = subprocess.run(
+ ["git", "diff", "--name-status", "-z", "--no-renames", base, "HEAD"],
+ capture_output=True, text=True, check=True).stdout.split("\0")
+ svgs, icon_folders, other_files = {}, set(), []
+ for status, path in zip(out[0::2], out[1::2]):
+ if path == "devicon.json":
+ continue
+ parts = path.split("/") # icons//
+ if len(parts) == 3 and parts[0] == "icons":
+ icon_folders.add(parts[1])
+ if status != "D" and parts[2].endswith(".svg"):
+ svgs.setdefault(parts[1], []).append(parts[2])
+ else:
+ other_files.append(path)
try:
base_json = json.loads(subprocess.run(["git", "show", f"{base}:devicon.json"],
@@ -100,9 +111,12 @@ def get_pr_icon_changes(devicon_json: List[dict], base: str="HEAD^1"):
except subprocess.CalledProcessError:
base_json = []
base_icons = {icon["name"]: icon for icon in base_json}
+ current_names = {icon["name"] for icon in devicon_json}
changed_json = [icon["name"] for icon in devicon_json if base_icons.get(icon["name"]) != icon]
+ changed_json += [name for name in base_icons if name not in current_names]
- return {"svgs": svgs, "json": changed_json, "existing": set(base_icons)}
+ return {"svgs": svgs, "icon_folders": sorted(icon_folders), "other_files": other_files,
+ "json": changed_json, "existing": set(base_icons)}
def get_svg_versions(icon: dict, svg_files: List[str]) -> Tuple[List[str], List[str]]:
@@ -136,7 +150,8 @@ def check_pr_title(pr_title: str, icon: dict, is_new: bool, versions: List[str])
:param icon, the devicon.json object of the icon whose SVGs the PR changes.
:param is_new, whether the icon is new (not in the target branch's devicon.json).
:param versions, the SVG versions the PR adds/changes.
- :return a string with the problems and the expected title, or "" if it's fine.
+ :return (problems, expected title): problems is a string with the issues and
+ the expected title, or "" if the title is fine.
"""
kind = "new" if is_new else "update"
expected = f"{kind} icon: {icon['name']} ({', '.join(versions)})"
@@ -158,8 +173,8 @@ def check_pr_title(pr_title: str, icon: dict, is_new: bool, versions: List[str])
problems.append(f"the versions should be the SVGs this PR {verb}: {', '.join(versions)}")
if problems:
- return "PR title issues:\n" + "\n".join(f"- {p}" for p in problems) + f"\n\nExpected title:\n{expected}"
- return ""
+ return "PR title issues:\n" + "\n".join(f"- {p}" for p in problems) + f"\n\nExpected title:\n{expected}", expected
+ return "", expected
def is_svg_in_font_attribute(svg_file_path: Path, devicon_object: dict):
diff --git a/.github/scripts/check_icon_pr.py b/.github/scripts/check_icon_pr.py
index 1eb8cf8c7..f3d45168b 100644
--- a/.github/scripts/check_icon_pr.py
+++ b/.github/scripts/check_icon_pr.py
@@ -11,8 +11,14 @@
def main():
"""
Check the icons touched by this PR, found from the changed files: the
- `devicon.json` entries and the SVGs. If the PR changes SVGs, also check that
- it covers a single icon and that the PR title describes it.
+ `devicon.json` entries and the SVGs.
+
+ An icon PR (one that changes anything in icons/) must only change one
+ icons// folder and that icon's `devicon.json` entry, and its title must
+ describe it. If the title is the only thing wrong about the PR's scope, the
+ expected title is written to './expected_title.txt' so the bot can rename the
+ PR, instead of reporting it as an error.
+
Error messages are written to './err_messages.txt' (empty if all is well).
"""
args = arg_getters.get_check_icon_pr_args()
@@ -20,35 +26,51 @@ def main():
all_icons = filehandler.get_json_file_content(args.devicon_json_path)
icons_by_name = {icon["name"]: icon for icon in all_icons}
changes = util.get_pr_icon_changes(all_icons, args.base)
+ print("Changed icon folders:", changes["icon_folders"])
print("Changed SVGs:", changes["svgs"])
print("Changed devicon.json entries:", changes["json"])
+ print("Other changed files:", changes["other_files"])
err_msg = []
#First check if devicon.json is sorted
if sorted(all_icons, key=lambda d: d['name']) != all_icons:
err_msg.append(f"devicon.json is not sorted correctly.\nPlease make sure that your icon is added in the `devicon.json` file at the correct alphabetic position\nas seen here: https://github.com/devicons/devicon/wiki/Updating-%60devicon.json%60")
- # An icon PR changes the SVGs of exactly one icon, and its title says which
- svg_icons = sorted(changes["svgs"])
- if len(svg_icons) > 1:
- err_msg.append(f"This PR changes the SVGs of several icons ({', '.join(svg_icons)}).\nPlease open a separate PR for each icon.")
- for name in svg_icons:
- if name not in icons_by_name:
+ # An icon PR only touches one icons// folder and that icon's devicon.json entry
+ icon_folders = changes["icon_folders"]
+ if icon_folders:
+ if changes["other_files"]:
+ err_msg.append("Icon PRs should only change files in `icons//` and `devicon.json`, but this PR also changes:\n" +
+ "\n".join(f"- {path}" for path in changes["other_files"]) +
+ "\nPlease remove these changes from this PR (open a separate PR for them if they're needed).")
+ if len(icon_folders) > 1:
+ err_msg.append(f"This PR changes several icons ({', '.join(icon_folders)}).\nPlease open a separate PR for each icon.")
+
+ expected_title = ""
+ if len(icon_folders) == 1:
+ name = icon_folders[0]
+ other_entries = [n for n in changes["json"] if n != name]
+ if other_entries:
+ err_msg.append(f"Icon PRs should only change the `devicon.json` entry of their own icon (\"{name}\"), but this PR also changes: {', '.join(other_entries)}.")
+
+ if name in changes["svgs"] and name not in icons_by_name:
err_msg.append(f"`icons/{name}/` was changed, but `devicon.json` has no entry named \"{name}\".")
- if len(svg_icons) == 1 and svg_icons[0] in icons_by_name:
- name = svg_icons[0]
- icon = icons_by_name[name]
- versions, invalid_files = util.get_svg_versions(icon, changes["svgs"][name])
- if invalid_files:
- err_msg.append("These SVG file names don't match our pattern of `-(original|plain|line)(-wordmark)?.svg`:\n" +
- "\n".join(f"- icons/{name}/{file}" for file in invalid_files))
- if versions:
- title_err = util.check_pr_title(args.pr_title, icon, name not in changes["existing"], versions)
- if title_err:
- err_msg.append(title_err)
+ elif name in changes["svgs"]:
+ icon = icons_by_name[name]
+ versions, invalid_files = util.get_svg_versions(icon, changes["svgs"][name])
+ if invalid_files:
+ err_msg.append("These SVG file names don't match our pattern of `-(original|plain|line)(-wordmark)?.svg`:\n" +
+ "\n".join(f"- icons/{name}/{file}" for file in invalid_files))
+ if versions:
+ title_err, title = util.check_pr_title(args.pr_title, icon, name not in changes["existing"], versions)
+ # Only rename automatically when the PR's scope is clean
+ if title_err and not (changes["other_files"] or other_entries or invalid_files):
+ expected_title = title
+ elif title_err:
+ err_msg.append(title_err)
# Check every icon this PR touches
- to_check = sorted(set(svg_icons) | set(changes["json"]))
+ to_check = sorted(set(icon_folders) | set(changes["json"]))
for name in to_check:
if name not in icons_by_name:
continue
@@ -73,6 +95,9 @@ def main():
err_msg.append(svg_err)
filehandler.write_to_file("./err_messages.txt", "\n\n".join(err_msg))
+ filehandler.write_to_file("./expected_title.txt", expected_title)
+ if expected_title:
+ print(f"PR title should be: {expected_title}")
print("Task completed.")
except Exception as e:
filehandler.write_to_file("./err_messages.txt", str(e))
diff --git a/.github/workflows/check_icon_pr.yml b/.github/workflows/check_icon_pr.yml
index cdded39ae..e8109fbb1 100644
--- a/.github/workflows/check_icon_pr.yml
+++ b/.github/workflows/check_icon_pr.yml
@@ -50,22 +50,32 @@ jobs:
PR_TITLE: ${{ github.event.pull_request.title }}
run: python ./.github/scripts/check_icon_pr.py "$PR_TITLE" ./icons ./devicon.json
- # Uploaded even if the script crashed: it writes the error to this file
+ # Uploaded even if the script crashed: it writes the error to err_messages.txt.
+ # expected_title.txt is set when the bot should rename the PR.
- name: Upload the err messages
uses: actions/upload-artifact@v4
if: always()
with:
name: err_messages
- path: ./err_messages.txt
+ path: |
+ ./err_messages.txt
+ ./expected_title.txt
if-no-files-found: ignore
- # Fail the check when issues were found, so the PR shows a red check.
- # post_check_icon_pr_comment.yml posts the details as a comment.
+ # Fail the check when issues were found or the title is wrong, so the PR
+ # shows a red check. post_check_icon_pr_comment.yml posts the details and
+ # renames the PR; the rename re-runs this check.
- name: Fail if issues were found
shell: bash
run: |
+ FAIL=0
+ if [ -s expected_title.txt ]; then
+ echo "::error::PR title should be: $(cat expected_title.txt) (the check-bot will rename the PR)"
+ FAIL=1
+ fi
if [ -s err_messages.txt ]; then
echo "::error::Issues found in this PR (details in the check-bot comment):"
cat err_messages.txt
- exit 1
+ FAIL=1
fi
+ exit $FAIL
diff --git a/.github/workflows/post_check_icon_pr_comment.yml b/.github/workflows/post_check_icon_pr_comment.yml
index 6951293d6..958b35cc2 100644
--- a/.github/workflows/post_check_icon_pr_comment.yml
+++ b/.github/workflows/post_check_icon_pr_comment.yml
@@ -4,6 +4,12 @@ on:
workflows: ['Check Icon PR']
types:
- completed
+
+permissions:
+ actions: read
+ issues: write
+ pull-requests: write
+
jobs:
post_result_of_svg_check:
name: Post the result of the Check SVG Action
@@ -15,66 +21,141 @@ jobs:
steps:
- name: Download workflow artifact
uses: dawidd6/action-download-artifact@v7
- if: success()
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: check_icon_pr.yml
run_id: ${{ github.event.workflow_run.id }}
- - name: Read the pr_num file
- if: success()
- id: pr_num_reader
- uses: juliangruber/read-file-action@v1.1.6
+ # For PRs from forks the check runs the fork's own workflow code, so its
+ # artifacts can't be trusted. Only act on the PR if its head is the commit
+ # that was checked.
+ - name: Verify the PR
+ id: pr
+ uses: actions/github-script@v7
with:
- path: ./pr_num/pr_num.txt
+ script: |
+ const fs = require('fs');
+ const num = parseInt(fs.readFileSync('./pr_num/pr_num.txt', 'utf8').trim(), 10);
+ if (!Number.isInteger(num) || num <= 0) {
+ core.setFailed('pr_num artifact is not a PR number');
+ return;
+ }
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: num
+ });
+ const checked = context.payload.workflow_run.head_sha;
+ if (pr.head.sha !== checked) {
+ core.setFailed(`PR #${num} is at ${pr.head.sha}, but the check ran on ${checked}. Not acting on it.`);
+ return;
+ }
+ core.setOutput('number', String(num));
+
+ - name: Read the expected title
+ id: title
+ run: |
+ TITLE=""
+ if [ -f ./err_messages/expected_title.txt ]; then
+ TITLE=$(head -c 300 ./err_messages/expected_title.txt | tr -d '\r\n')
+ fi
+ echo "expected=$TITLE" >> $GITHUB_OUTPUT
- - name: Read the err message file
- if: success()
- id: err_message_reader
- uses: juliangruber/read-file-action@v1.1.6
+ # Renaming with the bot app's token (not GITHUB_TOKEN) re-runs the check
+ # on the new title. See build_weekly.yml for the app setup; it also needs
+ # the "Pull requests: Read and write" permission for this.
+ - name: Get bot token
+ id: bot-token
+ if: steps.title.outputs.expected != '' && vars.BOT_APP_ID != ''
+ uses: actions/create-github-app-token@v2
with:
- path: ./err_messages/err_messages.txt
+ app-id: ${{ vars.BOT_APP_ID }}
+ private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- - name: Comment on the PR about the result - SVG Error
- uses: jungwinter/comment@v1.1.0 # let us comment on a specific PR
- if: success() && (steps.err_message_reader.outputs.content != '')
+ - name: Rename the PR
+ if: steps.title.outputs.expected != ''
+ uses: actions/github-script@v7
env:
- MESSAGE: |
- Hi!
-
- I'm the `check-bot` and we have some issues with your PR:
+ EXPECTED: ${{ steps.title.outputs.expected }}
+ PR_NUM: ${{ steps.pr.outputs.number }}
+ BOT_TOKEN: ${{ steps.bot-token.outputs.token }}
+ with:
+ script: |
+ const expected = process.env.EXPECTED;
+ const version = '(original|plain|line)(-wordmark)?';
+ const valid = new RegExp(`^(new|update) icon: [a-z0-9._-]+ \\(${version}(, ${version})*\\)$`);
+ if (expected.length > 200 || !valid.test(expected)) {
+ core.setFailed(`Refusing to use an unexpected title: ${expected}`);
+ return;
+ }
- ```
- {0}
- ```
+ const pull_number = parseInt(process.env.PR_NUM, 10);
+ const repo = { owner: context.repo.owner, repo: context.repo.repo };
+ const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number });
+ const oldTitle = pr.title;
- Check our [CONTRIBUTING guide](https://github.com/devicons/devicon/wiki/SVG-Standards) for more details regarding these errors.
+ if (!process.env.BOT_TOKEN) {
+ // Without the bot app a rename wouldn't re-run the check, so ask instead
+ await github.rest.issues.createComment({ ...repo, issue_number: pull_number, body: [
+ "Hi!", "",
+ "I'm the `check-bot`. This PR's title doesn't match the icon it changes. Please rename it to:", "",
+ "```", expected, "```", "",
+ "Thanks for your help,", "SVG-Checker Bot :smile:"
+ ].join('\n') });
+ return;
+ }
- Please address these issues. When you push new commits or edit the PR title, I will check your PR again.
+ const bot = github.getOctokit(process.env.BOT_TOKEN);
+ await bot.rest.pulls.update({ ...repo, pull_number, title: expected });
+ await github.rest.issues.createComment({ ...repo, issue_number: pull_number, body: [
+ "Hi!", "",
+ "I'm the `check-bot`. The PR title didn't match the icon this PR changes, so I renamed it:", "",
+ "```", `from: ${oldTitle}`, `to: ${expected}`, "```", "",
+ "The title becomes the commit message and the line in the release notes. I'll check the PR again with the new title.", "",
+ "Thanks for your help,", "SVG-Checker Bot :smile:"
+ ].join('\n') });
- Thanks for your help,
- SVG-Checker Bot :smile:
+ - name: Comment on the PR about the result - SVG Error
+ uses: actions/github-script@v7
+ env:
+ PR_NUM: ${{ steps.pr.outputs.number }}
with:
- type: create
- issue_number: ${{ steps.pr_num_reader.outputs.content }}
- token: ${{ secrets.GITHUB_TOKEN }}
- body: ${{ format(env.MESSAGE, steps.err_message_reader.outputs.content) }}
+ script: |
+ const fs = require('fs');
+ const file = './err_messages/err_messages.txt';
+ const errors = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim() : '';
+ if (!errors) return;
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: parseInt(process.env.PR_NUM, 10),
+ body: [
+ "Hi!", "",
+ "I'm the `check-bot` and we have some issues with your PR:", "",
+ "```", errors, "```", "",
+ "Check our [CONTRIBUTING guide](https://github.com/devicons/devicon/wiki/SVG-Standards) for more details regarding these errors.", "",
+ "Please address these issues. When you push new commits or edit the PR title, I will check your PR again.", "",
+ "Thanks for your help,", "SVG-Checker Bot :smile:"
+ ].join('\n')
+ });
+ # Only with a verified PR number: never comment on a PR named by an unverified artifact
- name: Comment on the PR about the result - Failure
- uses: jungwinter/comment@v1.1.0 # let us comment on a specific PR
- if: failure()
+ if: failure() && steps.pr.outputs.number != ''
+ uses: actions/github-script@v7
env:
- MESSAGE: |
- Hi!
-
- I'm Devicons' SVG-Checker Bot and it seems we've ran into a problem. I'm supposed to check your svgs but I couldn't do my task due to an issue.
-
- Please let my maintainers know of the issues. They will take a look at my work and try to resolve the problem. Until then, please hang tight and sorry for the inconvenience.
-
- Cheers,
- SVG-Checker Bot :smile:
+ PR_NUM: ${{ steps.pr.outputs.number }}
with:
- type: create
- issue_number: ${{ steps.pr_num_reader.outputs.content }}
- token: ${{ secrets.GITHUB_TOKEN }}
- body: ${{ env.MESSAGE }}
+ script: |
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: parseInt(process.env.PR_NUM, 10),
+ body: [
+ "Hi!", "",
+ "I'm Devicons' SVG-Checker Bot and it seems we've ran into a problem. I'm supposed to check your svgs but I couldn't do my task due to an issue.", "",
+ "Please let my maintainers know of the issues. They will take a look at my work and try to resolve the problem. Until then, please hang tight and sorry for the inconvenience.", "",
+ "Cheers,", "SVG-Checker Bot :smile:"
+ ].join('\n')
+ });
diff --git a/CLAUDE.md b/CLAUDE.md
index f468d6a17..9f243b0d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,7 +45,7 @@ devicon/
### Repo setup the workflows depend on
- Ruleset on `master`: require PR + 1 approval, squash only, block force-push/deletion. Bypass: the bot GitHub App (always).
-- Bot GitHub App with `Contents: Read and write`, installed on the repo; `vars.BOT_APP_ID` + `secrets.BOT_APP_PRIVATE_KEY`.
+- Bot GitHub App with `Contents: Read and write` (pushes to master) and `Pull requests: Read and write` (renames icon PRs), installed on the repo; `vars.BOT_APP_ID` + `secrets.BOT_APP_PRIVATE_KEY`.
- Environments: `production` (required reviewers, gates releases), `release` (holds `NPM_AUTOMATION_TOKEN`). Forks run `npm publish --dry-run`.
- Labels: `release: major`, `release: minor`.
@@ -69,11 +69,13 @@ devicon/
Run `build_weekly.yml` via workflow_dispatch β it does exactly what the weekly run does (build if needed, release if anything changed). To build without releasing, reject the `production` approval. The `bump` input (`auto`/`patch`/`minor`/`major`) overrides the label-derived bump.
### PR titles
-Not used to find icons anywhere (bots, build, release all use changed files). Enforced by `check_icon_pr.yml` for PRs that change SVGs:
-- one icon per PR
+Not used to find icons anywhere (bots, build, release all use changed files). Enforced by `check_icon_pr.yml` for icon PRs (any change in `icons/`):
+- one icon per PR; only `icons//` and that icon's `devicon.json` entry may change β any other added/modified/deleted file or other `devicon.json` entry fails the check with a comment
- title `new icon: ()` (icon not in target branch's `devicon.json`) or `update icon: ()`; versions = the SVGs the PR adds/changes; case and version order don't matter
-- on mismatch the check-bot comments the problems plus the expected title; editing the title re-runs the check
-- `devicon.json`-only PRs (aliases, tagsβ¦) have no title requirement
+- wrong title on an otherwise clean-scoped PR β check fails, writes `expected_title.txt`, and `post_check_icon_pr_comment.yml` renames the PR with the bot app token (which re-runs the check) and comments old β new title. Without the bot app it comments the expected title instead
+- wrong title on a PR with scope problems β no rename; the title issue is part of the error comment
+- `devicon.json`-only PRs (aliases, tagsβ¦) have no title or scope requirement
+- `post_check_icon_pr_comment.yml` only acts on a PR whose head SHA matches the checked commit (fork runs' artifacts are untrusted) and only accepts titles matching the icon-title format
The title becomes the squash commit and the release-notes line.