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