diff --git a/hooks/pre-push b/hooks/pre-push
index 19e1085..4b2667d 100755
--- a/hooks/pre-push
+++ b/hooks/pre-push
@@ -1,6 +1,4 @@
#!/bin/sh
-kimelon git unlock all
-
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/pre-push'.\n"; exit 2; }
git lfs pre-push "$@"
diff --git a/kibot-ci.yml b/kibot-ci.yml
index 894f869..577f916 100644
--- a/kibot-ci.yml
+++ b/kibot-ci.yml
@@ -310,19 +310,51 @@ generate_panel:
fi
# =============================================================================
-# Stage: InvenTree
+# Stage: Astable (replaces the old InvenTree/hfsntree upload path)
# =============================================================================
-.inventree_base:
- stage: inventree
- image: python:3.11-slim
- before_script:
- - |
- cd hfsntree
- pip install -e . --quiet
- cd $CI_PROJECT_DIR
+#
+# 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.
-inventree_dev:
- extends: .inventree_base
+.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:
@@ -334,21 +366,40 @@ inventree_dev:
artifacts: true
- job: generate_3d
artifacts: true
- - job: generate_gerbers
- artifacts: true
- - job: generate_position
- artifacts: true
- - job: generate_panel
- artifacts: true
- optional: 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:
- |
- echo "Running InvenTree upload for dev branch (revision: dev)"
- cd hfsntree
- python main.py batch $CI_PROJECT_DIR/Fabrication --version-override dev -y
+ bash -s <<'BASH_SCRIPT'
+ set -euo pipefail
+ FAB="$CI_PROJECT_DIR/Fabrication"
+ SUB="$FAB/${PROJECT_NAME}"
+ VAL="$CI_PROJECT_DIR/Validation"
-inventree_main:
- extends: .inventree_base
+ 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:
@@ -372,28 +423,43 @@ inventree_main:
paths:
- Fabrication/
expire_in: 1 week
+ # Same alpine + ash limitation as astable_dev — invoke bash.
script:
- |
- cd hfsntree
+ bash -s <<'BASH_SCRIPT'
+ set -euo pipefail
+ FAB="$CI_PROJECT_DIR/Fabrication"
+ SUB="$FAB/${PROJECT_NAME}"
+ VAL="$CI_PROJECT_DIR/Validation"
- # MRs targeting main upload to "dev" revision for pre-ordering parts
- # Actual commits to main upload to the versioned revision
if [[ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]]; then
- echo "Running InvenTree upload for MR (revision: dev)"
- python main.py batch $CI_PROJECT_DIR/Fabrication --version-override dev -y
+ BRANCH=dev
else
- echo "Running InvenTree upload for main branch (revision: $VERSION)"
- python main.py batch $CI_PROJECT_DIR/Fabrication -y
-
- # Generate Samsung P&P files only on actual release
- echo "Generating Samsung pick-and-place files..."
- python main.py samsung $CI_PROJECT_DIR/Fabrication
-
- # Deactivate old revisions (keep only current release + dev)
- echo "Deactivating old InvenTree revisions..."
- python main.py deactivate-old $CI_PROJECT_DIR/Fabrication
+ BRANCH=main
fi
- cd $CI_PROJECT_DIR
+
+ 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
@@ -407,7 +473,7 @@ upload_packages:
needs:
- job: extract_version
artifacts: true
- - job: inventree_main
+ - job: astable_main
artifacts: true
script:
- apt-get update && apt-get -y install zip
diff --git a/scripts/upload-bom.sh b/scripts/upload-bom.sh
new file mode 100755
index 0000000..df8033d
--- /dev/null
+++ b/scripts/upload-bom.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+# Upload a BOM to Astable from CI. POSTs the multipart form to
+# /api/v1/bom/upload, then polls the returned jobId until the worker
+# finishes (done/failed). Prints the result JSON on done; exits non-zero
+# on any failure (timeout, parse error, worker failure, …) so the CI
+# job fails visibly.
+#
+# Env vars (required):
+# ASTABLE_URL — base URL (e.g. https://astable.timhadwen.com)
+# ASTABLE_API_TOKEN — bearer token minted at /manage/api-tokens
+#
+# Args:
+# --file — BOM CSV to upload (required)
+# --part-ipn — InvPart.ipn of the assembly (required)
+# --branch — drives revision targeting (required)
+# --commit-sha — for audit (optional)
+# --commit-message — drives "main" revision label (optional)
+# --poll-interval — default 3
+# --timeout — default 300 (5 min)
+#
+# Designed to be runnable under gitlab-ci-local — just set ASTABLE_URL
+# to your dev URL (e.g. http://host.docker.internal:3000 if Astable is
+# on the host) and gitlab-ci-local will pick it up from .env or the
+# `--variable` flag.
+
+set -euo pipefail
+
+POLL_INTERVAL=3
+TIMEOUT=300
+FILE=""
+CPL=""
+GERBERS=""
+PANEL_GERBERS=""
+STEP=""
+PCB_PDF=""
+SCHEMATIC_PDF=""
+IBOM_HTML=""
+BOM_XLSX=""
+PCBA_IMAGE=""
+PCB_IMAGE=""
+DRC_REPORT=""
+ERC_REPORT=""
+PART_IPN=""
+BRANCH=""
+COMMIT_SHA=""
+COMMIT_MESSAGE=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --file) FILE="$2"; shift 2 ;;
+ --cpl) CPL="$2"; shift 2 ;;
+ --gerbers) GERBERS="$2"; shift 2 ;;
+ --panel-gerbers) PANEL_GERBERS="$2"; shift 2 ;;
+ --step) STEP="$2"; shift 2 ;;
+ --pcb-pdf) PCB_PDF="$2"; shift 2 ;;
+ --schematic-pdf) SCHEMATIC_PDF="$2"; shift 2 ;;
+ --ibom-html) IBOM_HTML="$2"; shift 2 ;;
+ --bom-xlsx) BOM_XLSX="$2"; shift 2 ;;
+ --pcba-image) PCBA_IMAGE="$2"; shift 2 ;;
+ --pcb-image) PCB_IMAGE="$2"; shift 2 ;;
+ --drc-report) DRC_REPORT="$2"; shift 2 ;;
+ --erc-report) ERC_REPORT="$2"; shift 2 ;;
+ --part-ipn) PART_IPN="$2"; shift 2 ;;
+ --branch) BRANCH="$2"; shift 2 ;;
+ --commit-sha) COMMIT_SHA="$2"; shift 2 ;;
+ --commit-message) COMMIT_MESSAGE="$2"; shift 2 ;;
+ --poll-interval) POLL_INTERVAL="$2"; shift 2 ;;
+ --timeout) TIMEOUT="$2"; shift 2 ;;
+ *) echo "Unknown arg: $1" >&2; exit 2 ;;
+ esac
+done
+
+: "${ASTABLE_URL:?ASTABLE_URL must be set}"
+: "${ASTABLE_API_TOKEN:?ASTABLE_API_TOKEN must be set}"
+: "${FILE:?--file is required}"
+: "${PART_IPN:?--part-ipn is required}"
+: "${BRANCH:?--branch is required}"
+[[ -f "$FILE" ]] || { echo "BOM file not found: $FILE" >&2; exit 2; }
+[[ -z "$CPL" || -f "$CPL" ]] || { echo "CPL file not found: $CPL" >&2; exit 2; }
+[[ "$BRANCH" == "dev" || "$BRANCH" == "main" ]] || { echo "--branch must be dev or main" >&2; exit 2; }
+
+echo "[bom-upload] POST $ASTABLE_URL/api/v1/bom/upload"
+echo "[bom-upload] partIpn=$PART_IPN branch=$BRANCH file=$FILE${CPL:+ cpl=$CPL}"
+
+CURL_ARGS=(
+ -fsS -X POST
+ -H "authorization: Bearer $ASTABLE_API_TOKEN"
+ -F "file=@$FILE"
+ -F "partIpn=$PART_IPN"
+ -F "branch=$BRANCH"
+ -F "commitSha=$COMMIT_SHA"
+ -F "commitMessage=$COMMIT_MESSAGE"
+)
+[[ -n "$CPL" ]] && CURL_ARGS+=(-F "cpl=@$CPL")
+[[ -n "$GERBERS" ]] && CURL_ARGS+=(-F "gerbers=@$GERBERS")
+[[ -n "$PANEL_GERBERS" ]] && CURL_ARGS+=(-F "panelGerbers=@$PANEL_GERBERS")
+[[ -n "$STEP" ]] && CURL_ARGS+=(-F "step=@$STEP")
+[[ -n "$PCB_PDF" ]] && CURL_ARGS+=(-F "pcbPdf=@$PCB_PDF")
+[[ -n "$SCHEMATIC_PDF" ]] && CURL_ARGS+=(-F "schematicPdf=@$SCHEMATIC_PDF")
+[[ -n "$IBOM_HTML" ]] && CURL_ARGS+=(-F "ibomHtml=@$IBOM_HTML")
+[[ -n "$BOM_XLSX" ]] && CURL_ARGS+=(-F "bomXlsx=@$BOM_XLSX")
+[[ -n "$PCBA_IMAGE" ]] && CURL_ARGS+=(-F "pcbaImage=@$PCBA_IMAGE")
+[[ -n "$PCB_IMAGE" ]] && CURL_ARGS+=(-F "pcbImage=@$PCB_IMAGE")
+[[ -n "$DRC_REPORT" ]] && CURL_ARGS+=(-F "drcReport=@$DRC_REPORT")
+[[ -n "$ERC_REPORT" ]] && CURL_ARGS+=(-F "ercReport=@$ERC_REPORT")
+RESPONSE=$(curl "${CURL_ARGS[@]}" "$ASTABLE_URL/api/v1/bom/upload")
+
+JOB_ID=$(echo "$RESPONSE" | jq -r '.jobId // empty')
+POLL_URL=$(echo "$RESPONSE" | jq -r '.pollUrl // empty')
+LINE_COUNT=$(echo "$RESPONSE" | jq -r '.lineCount // 0')
+
+if [[ -z "$JOB_ID" || -z "$POLL_URL" ]]; then
+ echo "[bom-upload] enqueue failed: $RESPONSE" >&2
+ exit 1
+fi
+
+echo "[bom-upload] queued ($LINE_COUNT lines parsed) → job $JOB_ID"
+
+START=$(date +%s)
+while true; do
+ NOW=$(date +%s)
+ ELAPSED=$((NOW - START))
+ if (( ELAPSED > TIMEOUT )); then
+ echo "[bom-upload] timed out after ${TIMEOUT}s" >&2
+ exit 1
+ fi
+
+ STATE=$(curl -fsS \
+ -H "authorization: Bearer $ASTABLE_API_TOKEN" \
+ "$ASTABLE_URL$POLL_URL")
+ STATUS=$(echo "$STATE" | jq -r '.status')
+ STEP=$(echo "$STATE" | jq -r '.progress.message // empty')
+
+ case "$STATUS" in
+ done)
+ echo "[bom-upload] done"
+ echo "$STATE" | jq -r '.result | " added=\(.added) updated=\(.updated) removed=\(.removed) missingPartsCreated=\(.missingPartsCreated) unresolvedSuppliers=\(.unresolvedSuppliers)"'
+ REV=$(echo "$STATE" | jq -r '.result.revision.label // "(none)"')
+ echo " revision: $REV"
+ exit 0
+ ;;
+ failed)
+ ERROR=$(echo "$STATE" | jq -r '.error // "(no error message)"')
+ echo "[bom-upload] failed: $ERROR" >&2
+ exit 1
+ ;;
+ pending|running)
+ [[ -n "$STEP" ]] && echo " · $STATUS — $STEP"
+ sleep "$POLL_INTERVAL"
+ ;;
+ *)
+ echo "[bom-upload] unexpected status: $STATUS" >&2
+ echo "$STATE" >&2
+ exit 1
+ ;;
+ esac
+done