# KiCad CI Pipeline with hfsntree Integration # Include this file in your project's .gitlab-ci.yml and set PROJECT_NAME variable # # Example project .gitlab-ci.yml: # include: # - local: '.gitlab/kibot-ci.yml' # variables: # PROJECT_NAME: "my_project_name" workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never - if: $CI_COMMIT_BRANCH variables: GIT_STRATEGY: clone GIT_SUBMODULE_STRATEGY: recursive GIT_SUBMODULE_FORCE_HTTPS: "true" PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/kicad" stages: - preflight - fabrication - inventree - release image: name: ghcr.io/inti-cmnb/kicad9_auto:1.8.4 # ============================================================================= # Rule Templates # ============================================================================= .main_rules: rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"' - if: $CI_COMMIT_BRANCH == "main" - if: $GITLAB_CI == 'false' .dev_rules: rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"' - if: $CI_COMMIT_BRANCH == "dev" - if: $GITLAB_CI == 'false' # ============================================================================= # Stage: Preflight # ============================================================================= extract_version: stage: preflight script: - | # Version strategy: # - dev branch: always "dev" # - main branch: extract from commit message (e.g., "V1.2") if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then # On main, extract version from commit message VERSION=$(echo "$CI_COMMIT_MESSAGE" | grep -oE 'V[0-9]+(\.[0-9]+)*' | head -1) if [[ -z "$VERSION" ]]; then VERSION="$CI_COMMIT_SHORT_SHA" echo "Warning: No version found in commit message, using SHA" fi echo "Main branch version: $VERSION" else # On dev or any other branch, use "dev" VERSION="dev" echo "Dev branch version: $VERSION" fi echo "VERSION=$VERSION" >> build.env echo "Extracted version: $VERSION" artifacts: reports: dotenv: build.env run_erc: stage: preflight needs: - job: extract_version artifacts: true script: - | if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch" ]; then echo "Running ERC..." cat > /tmp/erc.kibot.yaml << 'EOF' kibot: version: 1 preflight: erc: enabled: true warnings_as_errors: false EOF kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -c /tmp/erc.kibot.yaml \ -d Validation/ fi artifacts: when: always paths: - Validation/ expire_in: 1 week run_drc: stage: preflight needs: - job: extract_version artifacts: true script: - | if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_pcb" ]; then echo "Running DRC..." # Extract part number from project name for text variables PART_NUM=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) cat > /tmp/drc.kibot.yaml << EOF kibot: version: 1 preflight: set_text_variables: - name: 'name' text: '${PART_NUM}' - name: 'rev' text: '${VERSION}' - name: 'rev_pcb' text: '${VERSION}' drc: enabled: true warnings_as_errors: false EOF kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -b ${PROJECT_NAME}/${PROJECT_NAME}.kicad_pcb \ -c /tmp/drc.kibot.yaml \ -d Validation/ fi artifacts: when: always paths: - Validation/ expire_in: 1 week # ============================================================================= # Stage: Fabrication # ============================================================================= .fabrication_base: stage: fabrication needs: - job: extract_version artifacts: true - job: run_erc artifacts: false - job: run_drc artifacts: false before_script: - | # Ensure submodules are initialized (needed for gitlab-ci-local) git submodule update --init --recursive 2>/dev/null || true # Export version and name for KiBot text variable injection export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "KiBot version variables set to: $VERSION" echo "KiBot name variable set to: $KIBOT_VAR_name" # Setup user config USER_FILE="$CI_PROJECT_DIR/.gitlab/configs/blank.kibot.yaml" if [ -f "${PROJECT_NAME}/kibot.yaml" ]; then USER_FILE="${PROJECT_NAME}/kibot.yaml" fi cp $USER_FILE $CI_PROJECT_DIR/.gitlab/configs/user.kibot.yaml artifacts: when: always paths: - Fabrication/ expire_in: 1 week generate_schematic: extends: .fabrication_base script: - | export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "Using version: $VERSION, name: $KIBOT_VAR_name" if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch" ]; then kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -c $CI_PROJECT_DIR/.gitlab/configs/sch.kibot.yaml \ -d $CI_PROJECT_DIR/Fabrication/${PROJECT_NAME} mv Fabrication/${PROJECT_NAME}/*.pdf Fabrication/ 2>/dev/null || true fi generate_bom: extends: .fabrication_base script: - | export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "Using version: $VERSION, name: $KIBOT_VAR_name" if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch" ]; then kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -c $CI_PROJECT_DIR/.gitlab/configs/sch.kibot.yaml \ -d $CI_PROJECT_DIR/Fabrication/${PROJECT_NAME} mv Fabrication/${PROJECT_NAME}/*.csv Fabrication/ 2>/dev/null || true mv Fabrication/${PROJECT_NAME}/*.xlsx Fabrication/ 2>/dev/null || true fi generate_3d: extends: .fabrication_base script: - | export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "Using version: $VERSION, name: $KIBOT_VAR_name" if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_pcb" ]; then kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -c $CI_PROJECT_DIR/.gitlab/configs/mech.kibot.yaml \ -d $CI_PROJECT_DIR/Fabrication/${PROJECT_NAME} mv Fabrication/${PROJECT_NAME}/*.step Fabrication/ 2>/dev/null || true fi generate_gerbers: extends: .fabrication_base script: - | export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "Using version: $VERSION, name: $KIBOT_VAR_name" if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_pcb" ]; then kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -c $CI_PROJECT_DIR/.gitlab/configs/pcb_main.kibot.yaml \ -d $CI_PROJECT_DIR/Fabrication/${PROJECT_NAME} mv Fabrication/${PROJECT_NAME}/*.zip Fabrication/ 2>/dev/null || true fi generate_position: extends: .fabrication_base script: - | export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "Using version: $VERSION, name: $KIBOT_VAR_name" if [ -f "${PROJECT_NAME}/${PROJECT_NAME}.kicad_pcb" ]; then kibot -e ${PROJECT_NAME}/${PROJECT_NAME}.kicad_sch \ -c $CI_PROJECT_DIR/.gitlab/configs/pos.kibot.yaml \ -d $CI_PROJECT_DIR/Fabrication/${PROJECT_NAME} mv Fabrication/${PROJECT_NAME}/*cpl*.csv Fabrication/ 2>/dev/null || true fi generate_panel: extends: .fabrication_base script: - | export KIBOT_VAR_rev="${VERSION}" export KIBOT_VAR_rev_pcb="${VERSION}" export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) echo "Using version: $VERSION, name: $KIBOT_VAR_name" # Find panel configuration PANEL_JSON=$(find ${PROJECT_NAME} -name "*_panel.json" 2>/dev/null | head -1) if [ -n "$PANEL_JSON" ] && [ -f "$PANEL_JSON" ]; then echo "Found panel config: $PANEL_JSON" # Create panel directory PANEL_NAME=$(basename "${PANEL_JSON%.json}") mkdir -p panels/${PANEL_NAME} PCB_FILE="${PROJECT_NAME}/${PROJECT_NAME}.kicad_pcb" # Run pre-panel script if exists if [ -f ".gitlab/.scripts/pre_panel.py" ]; then python3 .gitlab/.scripts/pre_panel.py $PCB_FILE $PANEL_JSON fi # Generate panel kikit panelize -p $PANEL_JSON $PCB_FILE panels/${PANEL_NAME}/${PANEL_NAME}.kicad_pcb # Copy required files for panel cp .gitlab/templates/micromelon_default/micromelon_default.kicad_sch panels/${PANEL_NAME}/${PANEL_NAME}.kicad_sch 2>/dev/null || true cp ${PROJECT_NAME}/fp-lib-table panels/${PANEL_NAME}/ 2>/dev/null || true # Copy libs folder for 3D models cp -r ${PROJECT_NAME}/libs panels/${PANEL_NAME}/ 2>/dev/null || true # Run post-panel script if exists if [ -f ".gitlab/.scripts/post_panel.py" ]; then python3 .gitlab/.scripts/post_panel.py panels/${PANEL_NAME}/${PANEL_NAME}.kicad_pro $PCB_FILE fi # Generate panel gerbers # Set name variable explicitly for panel (extract part number from project name) export KIBOT_VAR_name=$(echo "${PROJECT_NAME}" | cut -d"-" -f1) cd panels kibot -e ${PANEL_NAME}/${PANEL_NAME}.kicad_sch \ -c $CI_PROJECT_DIR/.gitlab/configs/panel.kibot.yaml \ -d $CI_PROJECT_DIR/Fabrication/panels cd $CI_PROJECT_DIR mv Fabrication/panels/*.zip Fabrication/ 2>/dev/null || true else echo "No panel configuration found, skipping panel generation" fi # ============================================================================= # Stage: Astable (replaces the old InvenTree/hfsntree upload path) # ============================================================================= # # Uploads the BOM + every fabrication output to Astable via # POST /api/v1/bom/upload. Astable owns the supplier-API # credentials, so the CI runner doesn't need Digi-Key / Mouser # tokens any more. The upload is strict: any MPN that can't be # resolved against an enabled supplier integration aborts the # upload and writes nothing. # # Revision-targeting follows the existing dev/main convention: # - dev branch: uploads to the part's DRAFT "dev" revision. # - main branch: uploads to the rev named from the commit # message's "VX.Y" token, promoted to ACTIVE. # - MR-to-main: uploads to "dev" (matches old behaviour). # # Required CI variables (set group-level on # Micromelon/education/hardware/, masked + protected): # # ASTABLE_URL e.g. https://astable.timhadwen.com # ASTABLE_API_TOKEN minted at /manage/api-tokens (ast_…) # # The script vendored at scripts/upload-bom.sh in this repo handles # the POST + polling loop. PROJECT_NAME flows through as the # part-ipn — Astable splits on the first hyphen to derive the # PCBA IPN ("A") + bare-PCB IPN ("") + display name. .astable_base: stage: inventree # keep stage name so existing `needs:` # downstream (upload_packages) still match # Plain alpine (not curlimages/curl) — we need to apk-add bash+jq # and curlimages/curl runs as a non-root user that can't write the # apk db. alpine:3.20 is ~5MB, identical pull cost. image: alpine:3.20 before_script: - apk add --no-cache bash curl jq # Each PCB project checks out kicad-ci as a submodule under # .gitlab/ (with GIT_SUBMODULE_STRATEGY: recursive at the top of # this file), so scripts/upload-bom.sh is available locally — # no raw-URL fetch needed, and the script's version stays in # lockstep with the submodule pointer the project pins to. - install -m 0755 "$CI_PROJECT_DIR/.gitlab/scripts/upload-bom.sh" /usr/local/bin/upload-bom.sh astable_dev: extends: .astable_base rules: - !reference [.dev_rules, rules] needs: - job: extract_version artifacts: true - job: generate_schematic artifacts: true - job: generate_bom artifacts: true - job: generate_3d artifacts: true # Script body wrapped in `bash -s` because alpine ships busybox ash # by default, which doesn't parse bash arrays (ARGS=(...)). The # quoted heredoc keeps GitLab from substituting variables — bash # sees the raw $CI_PROJECT_DIR etc. and expands them itself. script: - | bash -s <<'BASH_SCRIPT' set -euo pipefail FAB="$CI_PROJECT_DIR/Fabrication" SUB="$FAB/${PROJECT_NAME}" VAL="$CI_PROJECT_DIR/Validation" ARGS=( --file "$FAB/${PROJECT_NAME}_dev_bom.csv" --part-ipn "$PROJECT_NAME" --branch dev --commit-sha "$CI_COMMIT_SHA" --commit-message "$CI_COMMIT_MESSAGE" ) [ -f "$FAB/${PROJECT_NAME}_dev_cpl.csv" ] && ARGS+=(--cpl "$FAB/${PROJECT_NAME}_dev_cpl.csv") [ -f "$FAB/${PROJECT_NAME}_dev.step" ] && ARGS+=(--step "$FAB/${PROJECT_NAME}_dev.step") [ -f "$FAB/${PROJECT_NAME}_dev_schematic.pdf" ] && ARGS+=(--schematic-pdf "$FAB/${PROJECT_NAME}_dev_schematic.pdf") [ -f "$SUB/${PROJECT_NAME}_dev_ibom.html" ] && ARGS+=(--ibom-html "$SUB/${PROJECT_NAME}_dev_ibom.html") [ -f "$SUB/${PROJECT_NAME}_dev_bom.xlsx" ] && ARGS+=(--bom-xlsx "$SUB/${PROJECT_NAME}_dev_bom.xlsx") [ -f "$SUB/PCBA_${PROJECT_NAME}_dev.png" ] && ARGS+=(--pcba-image "$SUB/PCBA_${PROJECT_NAME}_dev.png") [ -f "$SUB/PCB_${PROJECT_NAME}_dev.png" ] && ARGS+=(--pcb-image "$SUB/PCB_${PROJECT_NAME}_dev.png") [ -f "$VAL/${PROJECT_NAME}-erc.html" ] && ARGS+=(--erc-report "$VAL/${PROJECT_NAME}-erc.html") [ -f "$VAL/${PROJECT_NAME}-drc.html" ] && ARGS+=(--drc-report "$VAL/${PROJECT_NAME}-drc.html") upload-bom.sh "${ARGS[@]}" BASH_SCRIPT astable_main: extends: .astable_base rules: - !reference [.main_rules, rules] needs: - job: extract_version artifacts: true - job: generate_schematic artifacts: true - job: generate_bom artifacts: true - job: generate_3d artifacts: true - job: generate_gerbers artifacts: true - job: generate_position artifacts: true - job: generate_panel artifacts: true optional: true artifacts: when: always paths: - Fabrication/ expire_in: 1 week # Same alpine + ash limitation as astable_dev — invoke bash. script: - | bash -s <<'BASH_SCRIPT' set -euo pipefail FAB="$CI_PROJECT_DIR/Fabrication" SUB="$FAB/${PROJECT_NAME}" VAL="$CI_PROJECT_DIR/Validation" if [[ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]]; then BRANCH=dev else BRANCH=main fi ARGS=( --file "$FAB/${PROJECT_NAME}_${VERSION}_bom.csv" --part-ipn "$PROJECT_NAME" --branch "$BRANCH" --commit-sha "$CI_COMMIT_SHA" --commit-message "$CI_COMMIT_MESSAGE" ) [ -f "$FAB/${PROJECT_NAME}_${VERSION}_cpl.csv" ] && ARGS+=(--cpl "$FAB/${PROJECT_NAME}_${VERSION}_cpl.csv") [ -f "$FAB/${PROJECT_NAME}_${VERSION}_JLC.zip" ] && ARGS+=(--gerbers "$FAB/${PROJECT_NAME}_${VERSION}_JLC.zip") [ -f "$FAB/${PROJECT_NAME}_panel_${VERSION}_JLC.zip" ] && ARGS+=(--panel-gerbers "$FAB/${PROJECT_NAME}_panel_${VERSION}_JLC.zip") [ -f "$FAB/${PROJECT_NAME}_${VERSION}.step" ] && ARGS+=(--step "$FAB/${PROJECT_NAME}_${VERSION}.step") [ -f "$FAB/${PROJECT_NAME}_${VERSION}_schematic.pdf" ] && ARGS+=(--schematic-pdf "$FAB/${PROJECT_NAME}_${VERSION}_schematic.pdf") [ -f "$SUB/${PROJECT_NAME}_${VERSION}_PCB.pdf" ] && ARGS+=(--pcb-pdf "$SUB/${PROJECT_NAME}_${VERSION}_PCB.pdf") [ -f "$SUB/${PROJECT_NAME}_${VERSION}_ibom.html" ] && ARGS+=(--ibom-html "$SUB/${PROJECT_NAME}_${VERSION}_ibom.html") [ -f "$SUB/${PROJECT_NAME}_${VERSION}_bom.xlsx" ] && ARGS+=(--bom-xlsx "$SUB/${PROJECT_NAME}_${VERSION}_bom.xlsx") [ -f "$SUB/PCBA_${PROJECT_NAME}_${VERSION}.png" ] && ARGS+=(--pcba-image "$SUB/PCBA_${PROJECT_NAME}_${VERSION}.png") [ -f "$SUB/PCB_${PROJECT_NAME}_${VERSION}.png" ] && ARGS+=(--pcb-image "$SUB/PCB_${PROJECT_NAME}_${VERSION}.png") [ -f "$VAL/${PROJECT_NAME}-erc.html" ] && ARGS+=(--erc-report "$VAL/${PROJECT_NAME}-erc.html") [ -f "$VAL/${PROJECT_NAME}-drc.html" ] && ARGS+=(--drc-report "$VAL/${PROJECT_NAME}-drc.html") upload-bom.sh "${ARGS[@]}" BASH_SCRIPT # ============================================================================= # Stage: Release # ============================================================================= upload_packages: stage: release rules: - if: $CI_COMMIT_TAG when: never - if: $CI_COMMIT_BRANCH == "main" needs: - job: extract_version artifacts: true - job: astable_main artifacts: true script: - apt-get update && apt-get -y install zip - | # Create single release zip with all fabrication outputs RELEASE_ZIP="${PROJECT_NAME}_${VERSION}.zip" zip -r "Fabrication/${RELEASE_ZIP}" Fabrication/ echo "Created release package: ${RELEASE_ZIP}" - echo 'import urllib.request,sys,os;f=open(sys.argv[1],"rb");req=urllib.request.Request(sys.argv[2],data=f.read(),method="PUT");req.add_header("JOB-TOKEN",os.environ["CI_JOB_TOKEN"]);urllib.request.urlopen(req);print("Uploaded",sys.argv[1])' > /tmp/upload.py - | # Upload release package RELEASE_ZIP="${PROJECT_NAME}_${VERSION}.zip" url="${PACKAGE_REGISTRY_URL}/${VERSION}/${RELEASE_ZIP}" echo "Uploading: Fabrication/${RELEASE_ZIP} to $url" python3 /tmp/upload.py "Fabrication/${RELEASE_ZIP}" "$url" artifacts: when: always paths: - Fabrication/ expire_in: 1 week create_release: stage: release image: registry.gitlab.com/gitlab-org/release-cli:latest rules: - if: $CI_COMMIT_TAG when: never - if: $CI_COMMIT_BRANCH == "main" needs: - job: extract_version artifacts: true - job: upload_packages artifacts: true script: - echo "Creating release for version $VERSION" release: tag_name: '$VERSION' name: 'Release $VERSION' description: 'Automated release for $VERSION'