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
    1. Define the next release version number based on the conventions
    2. -
    3. Checkout development as draft-release branch
    4. -
    5. Bump the package version using npm version vMAJOR.MINOR.PATCH -m "bump npm version to vMAJOR.MINOR.PATCH" (see #487)
    6. +
    7. Checkout master as draft-release branch: git checkout master && git checkout -b draft-release
    8. +
    9. Bump the package version using npm version vMAJOR.MINOR.PATCH -m "bump npm version to vMAJOR.MINOR.PATCH"
    10. Push the branch draft-release
    11. 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
    12. -
    13. Review and approve the auto-create pull request created by the action of the step above
    14. -
    15. 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) + ? `![${name}](${baseUrl}/${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 => `![${f}](${baseUrl}/${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.

    -Open in Gitpod +Open in Gitpod > **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:

      -
    1. Define the next release version number based on the conventions
    2. -
    3. Checkout master as draft-release branch: git checkout master && git checkout -b draft-release
    4. -
    5. Bump the package version using npm version vMAJOR.MINOR.PATCH -m "bump npm version to vMAJOR.MINOR.PATCH"
    6. -
    7. Push the branch draft-release
    8. -
    9. 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
    10. -
    11. Review, approve and merge the auto-created pull request
    12. -
    13. 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. -
      • -
      -
    14. -
    15. Since it was already reviewed, a maintainer can merge it (DON'T perform a squash-merge).
    16. -
    17. 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.
    18. -
    19. 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).
    20. +
    21. 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.
    22. +
    23. 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.
    24. +
    25. The publish job runs npm_publish.yml, which updates the npm package.
    26. 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:

        -
      1. 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.
      2. -
      3. 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.
      4. +
      5. 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.
      6. +
      7. 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.
      8. The publish job runs npm_publish.yml, which updates the npm package.
      9. 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.