diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index ed51794a8..e8ed9963f 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -44,6 +44,9 @@ concurrency: env: ARSTACK_FIRMWARE_VERSION: '0.1.1' ARSTACK_PROTOCOL_VERSION: '1' + # Reusable PR workflows expose github.sha as a synthetic merge SHA. + # Compile identity and manifest must use the exact checked-out PR HEAD. + ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} jobs: build-esp32p4: @@ -63,6 +66,21 @@ jobs: # synthetic merge SHA that may change between queue and build. ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Pin semantic firmware build ID to the exact checkout + run: | + set -euo pipefail + if [[ ! "$ARSTACK_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Missing exact firmware source commit' >&2 + exit 1 + fi + checkout_commit="$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)" + if [[ "$checkout_commit" != "$ARSTACK_SOURCE_COMMIT" ]]; then + echo "Firmware checkout drift: $checkout_commit != $ARSTACK_SOURCE_COMMIT" >&2 + exit 1 + fi + echo "ARSTACK_FIRMWARE_BUILD_ID=${ARSTACK_SOURCE_COMMIT:0:16}" >> "$GITHUB_ENV" + echo "Exact firmware build identity pinned to ${ARSTACK_SOURCE_COMMIT:0:16}" + - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 @@ -134,10 +152,7 @@ jobs: test -s "build/${image}" - name: Create release manifest and SHA-256 contract - env: - # Container git may lack safe.directory or even be unavailable. - # The checkout explicitly selected this verified PR-head SHA. - ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + # Job-scoped ARSTACK_SOURCE_COMMIT is the exact checkout identity. run: | set -euo pipefail image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" @@ -163,6 +178,7 @@ jobs: "sha256": sha256, "espIdf": "5.5.4", "sourceCommit": source_commit, + "buildId": source_commit[:16], } with open("build/firmware-manifest.json", "w", encoding="utf-8", newline="\n") as handle: json.dump(manifest, handle, indent=2, sort_keys=True) @@ -183,6 +199,11 @@ jobs: assert 'SMPSYNCH-AUTO' in manifest['capabilities'] assert 'PROFILE-BINARY-V1' in manifest['capabilities'] assert __import__('re').fullmatch(r'[0-9a-f]{40}', manifest['sourceCommit']) + expected_build_id = manifest['sourceCommit'][:16] + assert manifest['buildId'] == expected_build_id + # The app itself must advertise the identity in IDENTIFY. A valid SHA + # and sourceCommit in manifest alone cannot prove which ELF was built. + assert expected_build_id.encode('ascii') in payload, 'Firmware image does not contain the promised semantic build identity' assert actual == manifest['sha256'] print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index c0b36c5ee..3abdb754d 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -206,7 +206,7 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.device.deviceVerified - ? panel.device.deviceProduct + " · " + panel.device.deviceTarget + " · firmware v" + panel.device.firmwareVersion + ? panel.device.deviceProduct + " · " + panel.device.deviceTarget + " · firmware v" + panel.device.firmwareVersion + " · build " + (panel.device.firmwareBuildId.length > 0 ? panel.device.firmwareBuildId : "unknown") : (panel.hasPort ? firmwarePort.currentText + " is visible. Device identity and recovery decisions remain owned by the guided session." : "Connect the ESP32-P4 programming USB cable, then refresh the port list.") @@ -274,7 +274,7 @@ SurfacePanel { text: panel.firmware.busy ? "The session supervisor currently owns the firmware tool. Keep USB connected until Studio reports a terminal result." : (panel.session && panel.session.firmwareUpdateAvailable - ? "The board firmware is compatible and remains usable. A newer build is available as an optional guided update." + ? "Device firmware is compatible, but its build ID differs from this package. Keep using it or install the bundled build when convenient. Copy diagnostics if verification fails." : "When Studio reports Firmware required, use the guided action. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write.") color: panel.theme.textSoft font.family: panel.uiFont @@ -289,7 +289,7 @@ SurfacePanel { uiFont: panel.uiFont implicitWidth: 172 text: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) - ? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Update available") + ? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Install bundled build") : "Reinstall current" visible: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index c4e75cf88..032056bd6 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -703,7 +703,16 @@ SurfacePanel { } RowLayout { Layout.fillWidth: true + spacing: 8 Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Copy diagnostics" + visible: !ribbon.firmwareResultSuccess + implicitWidth: 145 + onClicked: StudioClipboard.copyText(smartSession.diagnosticsText()) + } CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "OK"; tone: ribbon.firmwareResultSuccess ? "success" : "normal"; implicitWidth: 100; onClicked: resultDialog.close() } Item { Layout.fillWidth: true } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index cc2111514..55e1e90da 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -34,6 +34,7 @@ class DeterministicSessionHarness final { {"optional bootloader wait -> cancel restores supervisor", optionalBootloaderWaitCanCancel()}, {"firmware stage projection -> supervisor-owned", firmwareStageProjectionIsExplicit()}, {"firmware verification failure remains copyable after READY", firmwareFailureRemainsDiagnosable()}, + {"firmware build mismatch explains expected and observed IDs", reconnectBuildMismatchPreservesEvidence()}, {"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()}, {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, @@ -320,6 +321,28 @@ class DeterministicSessionHarness final { fixture.session.startReady(); } + static bool reconnectBuildMismatchPreservesEvidence() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto stale = identity(); + stale.buildId = QStringLiteral("fedcba9876543210"); + seedVerified(fixture, stale, QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.started_ = false; + session.updateRequested_ = true; + session.updateWasOptional_ = true; + session.updateStage_ = SmartSessionController::UpdateStage::reconnecting; + session.reconcile(); + const QString evidence = session.firmwareUpdateResult(); + return !session.updateRequested_ && + session.state() == QStringLiteral("READY") && + session.firmwareUpdateAvailable() && + evidence.contains(QStringLiteral("fedcba9876543210")) && + evidence.contains(QStringLiteral("0123456789abcdef")) && + evidence.contains(QStringLiteral("Copy diagnostics")) && + session.diagnosticsText().contains(evidence); + } + static bool optionalBootloaderWaitCanCancel() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index d1646d2d0..8c052266e 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -1209,9 +1209,15 @@ void SmartSessionController::reconcile() { const QString observed = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy/unknown firmware") : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + const QString installedBuild = deviceFirmwareBuildId_.isEmpty() + ? QStringLiteral("missing/unknown") : deviceFirmwareBuildId_; + const QString expectedBuild = expectedFirmwareBuildId().isEmpty() + ? QStringLiteral("missing/unknown") : expectedFirmwareBuildId(); latchFirmwareFailure(QStringLiteral( - "Firmware was written, but reconnect verification reported %1 instead of the exact bundled build for v%2.") - .arg(observed, expectedFirmwareVersion())); + "Firmware write/reset completed and ESP32-P4 reconnected, but exact build verification failed: " + "%1, installed build %2; package expects v%3, build %4. " + "A compatible existing firmware may still run. Copy diagnostics before retrying.") + .arg(observed, installedBuild, expectedFirmwareVersion(), expectedBuild)); emit firmwareUpdateFinished(false); reconcile(); return;