KiCAD is my favourite PCB design program. I use it for Hobby projects as well as in my day job. I even wrote a rather successful plugin called kicad-jlcpcb-tools for it that lets the user select parts from the JLCPCB catalog and assign them to the footprints. Furthermore it generates the fabrication data consisting of a zip with all Gerber files and the Excellon drill file as well as a BOM CSV file and a CPL CSV file. These three files can be uploaded to JLCPCBs website to get an instant quote for the PCB including assembly.
I’m quite happy with this plugin but lately I don’t have enough spare time to keep up with all the upcoming issues and feature requests. What also bothers me is the fact that JLCPCB is a Chinese company. The do great work and deliver PCBs very fast, but especially for my day job, I need a reliable source for the PCBs. With the fast changing political things that happen around the world nowadays, I don’t feel comfortable that my company is reliant on a supplier from China. Also I want to strengthen European companies (I’m aware that still a considerable portion is still coming from China).
One more thin is that I use git to track my changes to the PCB projects. Wouldn’t it be awesome if we could create fabrication data automatically?
A few weeks ago, I heard a talk at Cosin by @seto . He showed how he uses the fairly new CLI of KiCAD to automate his PCB fabrication data generation.
I tried this in the past but never had it to a point where I could use it reliably, also I used KiBot for this instead of “just” the CLI. KiBot is an amazing project with a ton of features. It lets you generate various outputs from the project files such as schematics as PDF, BOM files as CSV, Gerber and Excellon files, images and 3D renderings and many more.
So I thought I might give this another try and came up with a fairly elegant solution, at least for what I need.
Project setup π
Disclaimer: I use Gitea at the moment, but switching to Forgejo is on my ToDo list π
The following things should work with Gitea, Forgejo and Github and possibly more but I’ve just tested Gitea so far.
metadata.env π
In my project I create a metadata.env
file that looks like this:
1LONG_NAME=Smart-Meter-Interface
2SHORT_NAME=SMI
3VERSION=V1.0
4AUTHOR=Bouni
5KIBOT_CONFIG=.kibot/kibot-double-sided.yaml
6PCB_FILE=hardware/smart-meter-interface.kicad_pcb
7SCH_FILE=hardware/smart-meter-interface.kicad_sch
LONG_NAME
is the full name of the projectSHORT_NAME
is a short abbreviationVERSION
is the version of the projectKIBOT_CONFIG
is either-double-
or-single-
depending if your project needs images of both sides.PCB_FILE
is the relative path to the.kicad_pcb
fileSCH_FILE
is the relative path to the.kicad_sch
file
kibot configs π
In a subfolder called .kibot
I have two KiBot configs, one for single sided projects called kibot-single-sided.yaml
and one for double sided projects called kibot-double-sided.yaml
I show the double sided version as the single sided one has just a few outputs less, apart from that they are identical
1---
2kibot:
3 version: 1
4
5global:
6 output: "@LONG_NAME@-%r-%i.%x"
7 field_lcsc_part: LCSC # this specifies the field name in which the LCSC number of a component is stored, for example C12345
8
9import: # With this we import the template that generates all production files for JLCPCB
10 - file: JLCPCB
11 definitions:
12 _KIBOT_POS_ONLY_SMD: false # by default all THT components are omited, but I want to have them assembled as well
13
14outputs:
15
16 - name: 'Gerber' # gerber files for my European manufacturer
17 comment: 'Gerber files'
18 type: 'gerber'
19 dir: 'gerber'
20 layers:
21 - "F.Cu"
22 - "B.Cu"
23 - "F.Mask"
24 - "B.Mask"
25 - "F.Silkscreen"
26 - "B.Silkscreen"
27 - "Edge.Cuts"
28 options:
29 output: "@LONG_NAME@-%r-Gerber-%i.%x"
30
31 - name: 'Excellon' # excellon files for my European manufacturer
32 comment: 'Excellon drill files'
33 type: 'excellon'
34 dir: 'excellon'
35 options:
36 output: "@LONG_NAME@-%r-Drill.%x"
37
38 - name: 'Pick and Place' # Pick n Place files for my European manufacturer
39 comment: "Pick and Place file"
40 type: 'position'
41 dir: 'pick-and-place'
42 options:
43 output: "@LONG_NAME@-%r-PickAndPlace.%x"
44 format: "CSV"
45 separate_files_for_front_and_back: false
46
47 - name: 'BOM' # Bill of material for my European manufacturer
48 comment: "Bill of materials (XLSX)"
49 type: bom
50 options:
51 output: "@LONG_NAME@-%r-StΓΌckliste.%x"
52 format: "XLSX"
53 columns:
54 - "Row"
55 - "References"
56 - "Value"
57 - "Footprint"
58 - "Quantity per PCB"
59 - "MPN"
60 - "Manufacturer_Name"
61 normalize_values: true
62 xlsx:
63 logo: false
64 title: "StΓΌckliste @LONG_NAME@ %r"
65 hide_pcb_info: true
66 hide_stats_info: true
67highlight_empty: false
68
69 - name: 'Schematic' # schematic for my European manufacturer
70 comment: "Print schematic (PDF)"
71 type: pdf_sch_print
72 options:
73 output: "@LONG_NAME@-%r-Schema.%x"
74
75 - name: 'PCB TOP image' # top image of the PCB without components for my European manufacturer
76 comment: "PNG of the top side of the PCB"
77 type: pcbdraw
78 options:
79 output: "@LONG_NAME@-%r-Top.%x"
80 dpi: 1200
81 format: png
82
83 - name: 'PCB BOTTOM image' # bottom image of the PCB without components for my European manufacturer
84 comment: "PNG of the bottom side of the PCB"
85 type: pcbdraw
86 options:
87 output: "@LONG_NAME@-%r-Bottom.%x"
88 dpi: 1200
89 format: png
90 bottom: true
91
92 - name: "3D Blender top image" # 3D rendered image of the top for the README
93 comment: "@LONG_NAME@ 3D render from top (High Quality)"
94 type: blender_export
95 options:
96 render_options:
97 background1: "#8FBCF7FF"
98 background2: "#0B1E35FF"
99 samples: 20
100 resolution_y: 1440
101 resolution_x: 2560
102 point_of_view:
103 rotate_x: -5
104 outputs:
105 - type: render
106 output: "@LONG_NAME@-%r-Top-3D.png"
107
108 - name: "3D Blender bootom image" # 3D rendered image of the bottom for the README
109 comment: "@LONG_NAME@ 3D render from bottom (High Quality)"
110 type: blender_export
111 options:
112 render_options:
113 background1: "#8FBCF7FF"
114 background2: "#0B1E35FF"
115 samples: 20
116 resolution_y: 1440
117 resolution_x: 2560
118 point_of_view:
119 rotate_x: 5
120 rotate_y: 180
121 outputs:
122 - type: render
123 output: "@LONG_NAME@-%r-Bottom-3D.png"
Gitea action π
Note: this can be a Github action or a Forgejo action as well, that just requires a changed folder name as far a s I can tell.
1name: Auto Release
2run-name: Auto Release Gestartet von ${{ gitea.actor }} π
3on:
4 push:
5 branches: [main]
6 paths: [metadata.env]
This part controls when the action should run, I decided to only run it if I change the metadata.env, usually the only thing that changes is the version.
1env:
2 BASE_URL: https://gitea.bouni.de
Here I set the base URL of my gitea instance, maybe there is a smarter way to achieve this but it works π€·ββοΈ
jobs:
release:
runs-on: ubuntu-latest
outputs:
RELEASE_ID: ${{ steps.create_release.outputs.RELEASE_ID }}
This part is important, we need to pass the release id from one job to the other. For whatever reason the Gitea API uses a release id for the upload of files rather than the release tag π
1 steps:
2 - name: Checkout code
3 uses: actions/checkout@v3
4 with:
5 token: ${{ secrets.GITEA_TOKEN }}
6 fetch-depth: 0 # Needed for tagging
7 - name: Load metadata from metadata.env file
8 id: metadata
9 run: |
10 set -a
11 source metadata.env
12 set +a
13 echo "LONG_NAME=$LONG_NAME" >> $GITHUB_OUTPUT
14 echo "SHORT_NAME=$SHORT_NAME" >> $GITHUB_OUTPUT
15 echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
16 echo "AUTHOR=$AUTHOR" >> $GITHUB_OUTPUT
Checkout the repo, read the metadata.env into env vars and write the ones we need into $GITHUB_OUTPUT
1 - name: Install jq
2 id: install_jq
3 run: |
4 if ! command -v jq &> /dev/null; then
5 apt-get update && apt-get install -y jq
6 fi
Install jq , we need that later to parse JSON data
1 - name: Detect release type
2 id: version
3 run: |
4 # Check if it's a pre-release (contains RC, alpha, beta, dev, etc.)
5 if [[ "${{ steps.metadata.outputs.VERSION }}" =~ -RC[0-9]*$ ]] || \
6 [[ "${{ steps.metadata.outputs.VERSION }}" =~ -alpha[0-9]*$ ]] || \
7 [[ "${{ steps.metadata.outputs.VERSION }}" =~ -beta[0-9]*$ ]] || \
8 [[ "${{ steps.metadata.outputs.VERSION }}" =~ -dev[0-9]*$ ]] || \
9 [[ "${{ steps.metadata.outputs.VERSION }}" =~ -pre[0-9]*$ ]]; then
10 echo "PRERELEASE=true" >> $GITHUB_OUTPUT
11 echo "Detected PRE-RELEASE: ${{ steps.metadata.outputs.VERSION }}"
12 else
13 echo "PRERELEASE=false" >> $GITHUB_OUTPUT
14 echo "Detected STABLE RELEASE: ${{ steps.metadata.outputs.VERSION }}"
15 fi
16 - name: Check if tag exists
17 id: check_tag
18 run: |
19 if git rev-parse "${{ steps.metadata.outputs.VERSION }}" >/dev/null 2>&1; then
20 echo "exists=true" >> $GITHUB_OUTPUT
21 echo "Tag ${{ steps.metadata.outputs.VERSION }} already exists!"
22 exit 1
23 else
24 echo "exists=false" >> $GITHUB_OUTPUT
25 fi
Check if we have a Pre-Release and set a variable if so, then verify that this release does not exist. If it exists, we exit the action.
1 - name: Update README
2 if: steps.check_tag.outputs.exists == 'false'
3 run: |
4 # Replace version in README
5 sed -i -E 's|V[0-9]+\.[0-9]+(-RC[0-9]+)?|${{ steps.metadata.outputs.VERSION }}|g' README.md
6
7 # Delete pre-release warning
8 sed -i '/This is a pre-release version/d' README.md
9
10 # Add pre-release badge if it's a pre-release
11 if [[ "${{ steps.version.outputs.PRERELEASE }}" == "true" ]]; then
12 # Add pre-release warning to README
13 sed -i '1i\> **β οΈ This is a pre-release version. Use with caution!**' README.md
14 fi
Update the Versions in the README and add a pre-release warning at the top if it is a pre-release.
1 - name: Commit README changes
2 if: steps.check_tag.outputs.exists == 'false'
3 run: |
4 git config --local user.email "action@gitea.local"
5 git config --local user.name "Gitea Action Bot"
6 git add README.md
7 if [[ "${{ steps.version.outputs.PRERELEASE }}" == "true" ]]; then
8 git commit -m "Update README for pre-release ${{ steps.metadata.outputs.VERSION }}" || echo "No changes to commit"
9 else
10 git commit -m "Update README for release ${{ steps.metadata.outputs.VERSION }}" || echo "No changes to commit"
11 fi
Commit the README
1 - name: Create tag
2 if: steps.check_tag.outputs.exists == 'false'
3 run: |
4 git tag "${{ steps.metadata.outputs.VERSION }}"
5 git push origin main
6 git push origin "${{ steps.metadata.outputs.VERSION }}"
7 - name: Create release
8 id: create_release
9 if: steps.check_tag.outputs.exists == 'false'
10 run: |
11 if [[ "${{ steps.version.outputs.PRERELEASE }}" == "true" ]]; then
12 RELEASE_NAME="π§ Pre-release ${{ steps.metadata.outputs.VERSION }}"
13 RELEASE_BODY="Pre-release ${{ steps.metadata.outputs.VERSION }} - This is a pre-release version. \
14 It may contain bugs and is not recommended for production use. Use with caution!\n\n\n \
15 "
16 else
17 RELEASE_NAME="π Release ${{ steps.metadata.outputs.VERSION }}"
18 RELEASE_BODY="Release ${{ steps.metadata.outputs.VERSION }}\n\n\n \
19 "
20 fi
21
22 # Simple JSON without complex escaping
23 RESPONSE=$(curl -X POST \
24 -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
25 -H "Content-Type: application/json" \
26 -d "{\"tag_name\":\"${{ steps.metadata.outputs.VERSION }}\",\"name\":\"$RELEASE_NAME\",\"body\":\"$RELEASE_BODY\",\"draft\":false,\"prerelease\":${{ steps.version.outputs.PRERELEASE }}}" \
27 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases")
28
29 # Extract release ID and upload URL
30 RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
31 echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_OUTPUT
32
33 echo "Release created with ID: $RELEASE_ID"
34 - name: Release summary
35 id: release_summary
36 if: steps.check_tag.outputs.exists == 'false'
37 run: |
38 if [[ "${{ steps.version.outputs.PRERELEASE }}" == "true" ]]; then
39 echo "β
Pre-release ${{ steps.metadata.outputs.VERSION }} created successfully!"
40 else
41 echo "π Stable release ${{ steps.metadata.outputs.VERSION }} created successfully!"
42 fi
Create a tag, create a release (with a path to a not yet existing 3D rendered image of the board) and finally print a success message
Now we enter the second job where we generate the production files and images.
1 production-file-export:
2 name: Export Production files
3 needs: release
4 runs-on: ubuntu-latest
This needs the release job to have succeeded in order to run
1 steps:
2 - name: Checkout Repository
3 uses: actions/checkout@v4
4 with:
5 fetch-depth: 0
6 - name: Install zip
7 run: |
8 apt-get update && apt-get install -y zip
Checkout the repo and install zip, we need that later.
1 - name: Load metadata from metadata.env file
2 id: metadata
3 run: |
4 set -a
5 source metadata.env
6 set +a
7 echo "DATE=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT
8 echo "YEAR=$(date +%Y)" >> $GITHUB_OUTPUT
9 echo "LONG_NAME=$LONG_NAME" >> $GITHUB_OUTPUT
10 echo "SHORT_NAME=$SHORT_NAME" >> $GITHUB_OUTPUT
11 echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
12 echo "AUTHOR=$AUTHOR" >> $GITHUB_OUTPUT
13 echo "KIBOT_CONFIG=$KIBOT_CONFIG" >> $GITHUB_OUTPUT
14 echo "PCB_FILE=$PCB_FILE" >> $GITHUB_OUTPUT
15 echo "SCH_FILE=$SCH_FILE" >> $GITHUB_OUTPUT
Again read the metadata.env and store the values into $GITHUB_OUTPUT
1 - name: Update Files with correct Version and Date
2 run: |
3 sed -i "s/(rev \"[^\"]*\")/(rev \"${{ steps.metadata.outputs.VERSION }}\")/" ${{ steps.metadata.outputs.PCB_FILE}}
4 sed -i "s/(date \"[^\"]*\")/(date \"${{ steps.metadata.outputs.DATE }}\")/" ${{ steps.metadata.outputs.PCB_FILE}}
5 sed -i "s/<[vV]>/${{ steps.metadata.outputs.VERSION }}/g" ${{ steps.metadata.outputs.PCB_FILE}}
6 sed -i "s/<[yY]>/${{ steps.metadata.outputs.YEAR }}/g" ${{ steps.metadata.outputs.PCB_FILE}}
7 sed -i "s/<[aA]>/${{ steps.metadata.outputs.AUTHOR }}/g" ${{ steps.metadata.outputs.PCB_FILE}}
8 sed -i "s/<[sS]>/${{ steps.metadata.outputs.SHORT_NAME }}/g" ${{ steps.metadata.outputs.PCB_FILE}}
9 sed -i "s/(rev \"[^\"]*\")/(rev \"${{ steps.metadata.outputs.VERSION }}\")/" ${{ steps.metadata.outputs.SCH_FILE}}
10 sed -i "s/(date \"[^\"]*\")/(date \"${{ steps.metadata.outputs.DATE }}\")/" ${{ steps.metadata.outputs.SCH_FILE}}
Here we replace placeholders in the kicad_pcb
file, <V>
is replaced with the Version, <Y>
is replaced with the current Year, <A>
is replaced with the Author, <S>
is replaced with the short name.
We also update Version and date of the PCB and schematic so that the exported schematic hast the correct version in the frame.
1 - name: Run KiBot for Teltronik Production files
2 uses: INTI-CMNB/KiBot@v2_k9
3 with:
4 config: ${{ steps.metadata.outputs.KIBOT_CONFIG }}
5 dir: ./output
6 schema: ${{ steps.metadata.outputs.SCH_FILE}}
7 board: ${{ steps.metadata.outputs.PCB_FILE}}
8 additional_args: -E LONG_NAME=${{ steps.metadata.outputs.LONG_NAME }}
Now we run KiBot with the config, pcb file and schematic file specified in the metadata.env. We also pass the long name as an extta arg.
1 - name: Zip results
2 id: zip
3 run: |
4 cd output
5 zip -r ../${{ steps.metadata.outputs.DATE }}-${{ steps.metadata.outputs.LONG_NAME }}-ProductionFiles-${{ steps.metadata.outputs.VERSION }}.zip . -x "JLCPCB/*"
6 cd ..
7 echo "PRODUCTION_FILES=${{ steps.metadata.outputs.DATE }}-${{ steps.metadata.outputs.LONG_NAME }}-ProductionFiles-${{ steps.metadata.outputs.VERSION }}.zip" >> $GITHUB_OUTPUT
8 echo "JLCPCB_GERBER_FILES=$(find output/JLCPCB -maxdepth 1 -iname "*.zip" -type f)" >> $GITHUB_OUTPUT
9 echo "JLCPCB_BOM_FILE=$(find output/JLCPCB -maxdepth 1 -iname "*bom*" -type f)" >> $GITHUB_OUTPUT
10 echo "JLCPCB_CPL_FILE=$(find output/JLCPCB -maxdepth 1 -iname "*cpl*" -type f)" >> $GITHUB_OUTPUT
Finally we zip the generated files, except the JLCPCB files because these are not meant for the European manufacturer. The path to the resulting zip is again stored in a variable for later use. The JLCPCB file (zip with gerbers/excellon, BOM and CPL) are also stored in separate variables.
1 - name: Upload Release artifacts
2 env:
3 KIBOT_CONFIG: ${{ steps.metadata.outputs.KIBOT_CONFIG }}
4 run: |
5 curl -v -X POST \
6 -H "Authorization: token ${{ secrets.TOKEN }}" \
7 -H "Content-Type: application/octet-stream" \
8 -T ./output/${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Top.png \
9 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Top.png"
10 curl -v -X POST \
11 -H "Authorization: token ${{ secrets.TOKEN }}" \
12 -H "Content-Type: application/octet-stream" \
13 -T ./output/${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Top-3D.png \
14 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Top-3D.png"
15 if [[ "${KIBOT_CONFIG}" == *"double"* ]]; then
16 curl -v -X POST \
17 -H "Authorization: token ${{ secrets.TOKEN }}" \
18 -H "Content-Type: application/octet-stream" \
19 -T ./output/${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Bottom.png \
20 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Bottom.png"
21 curl -v -X POST \
22 -H "Authorization: token ${{ secrets.TOKEN }}" \
23 -H "Content-Type: application/octet-stream" \
24 -T ./output/${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Bottom-3D.png \
25 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Bottom-3D.png"
26 fi
27 curl -v -X POST \
28 -H "Authorization: token ${{ secrets.TOKEN }}" \
29 -H "Content-Type: application/octet-stream" \
30 -T ${{ steps.zip.outputs.PRODUCTION_FILES }} \
31 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=${{ steps.zip.outputs.PRODUCTION_FILES }}"
32 curl -v -X POST \
33 -H "Authorization: token ${{ secrets.TOKEN }}" \
34 -H "Content-Type: application/octet-stream" \
35 -T ${{ steps.zip.outputs.JLCPCB_GERBER_FILES }} \
36 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=JLCPCB-${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-Gerber.zip"
37 curl -v -X POST \
38 -H "Authorization: token ${{ secrets.TOKEN }}" \
39 -H "Content-Type: application/octet-stream" \
40 -T ${{ steps.zip.outputs.JLCPCB_BOM_FILE }} \
41 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=JLCPCB-${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-BOM.csv"
42 curl -v -X POST \
43 -H "Authorization: token ${{ secrets.TOKEN }}" \
44 -H "Content-Type: application/octet-stream" \
45 -T ${{ steps.zip.outputs.JLCPCB_CPL_FILE }} \
46 "${{ gitea.api_url }}/repos/${{ gitea.repository }}/releases/${{ needs.release.outputs.RELEASE_ID }}/assets?name=JLCPCB-${{ steps.metadata.outputs.LONG_NAME }}-${{ steps.metadata.outputs.VERSION }}-CPL.csv"
Finally we upload the files as release artifacts. If the string “double” occurs in the kibot config we upload Bottom files as well.
Readme.md π
In the Readme.md I always add the name of the project on top, followed by the version, for example # Smart-Merter-Interface V1.0
.
The Version will be automatically replaced by the action, so it doesn’t matter what exact version we put in here as long as its in the right format.
I also put two links to the front and if double sided, bottom pictures
1
2
3
The versions in these URLs are also replaced by the action
Page settings π
Another important step is to ensure that in the page settings of both, schematic and pcb the date, the version and the description are filled in.
Important settings π
Before we push the files to the Gitea instance, we need to prepare the repo.
First we need to enable Releases and Actions
Next we must add an action secret named TOKEN
The token mus have the following permissions
- write:package
- write:repository
A token can be generated under your user settings -> Applications -> Access Tokens
The result π
When everything goes according to plan, we end up with a release that has a 3D rendered picture of the Top side of the PCB in the description as well as several attached artifacts.
Also the README.md has nice renderings of the top and bottom now