From 0176f2f39c6cfc4b87984ef24a80673a125c234d Mon Sep 17 00:00:00 2001 From: RomanAlexandroff Date: Tue, 18 Aug 2026 11:21:38 +0200 Subject: [PATCH 1/3] introduce Clang config + edit Makefile --- .clang-format | 203 ++++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 43 ++++++++++- 2 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 .clang-format diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..ee4ae89 --- /dev/null +++ b/.clang-format @@ -0,0 +1,203 @@ +# Derived from Arduino IDE 2's default ClangFormat configuration: +# https://raw.githubusercontent.com/arduino/tooling-project-assets/main/other/clang-format-configuration/.clang-format +# +# Tailored toward The Norm Version 3 formatting (indent, braces, spacing, +# line length, etc.) as closely as ClangFormat's option set allows. +# +# Important: +# - A custom .clang-format fully overrides Arduino's default rather than +# merging with it. Options below are set explicitly on purpose. +# - ft_ / FT_ prefix naming rules from The Norm are intentionally excluded; +# they do not apply to this project. +# - Rules ClangFormat cannot express still need manual / Norminette checks +# (naming prefixes other than ft_, return parentheses, tab between return +# type and function name, function-size limits, forbidden constructs, etc.). +--- +AccessModifierOffset: -2 +AlignAfterOpenBracket: Align +AlignArrayOfStructures: None +AlignConsecutiveAssignments: None +AlignConsecutiveBitFields: None +AlignConsecutiveDeclarations: Consecutive +AlignConsecutiveMacros: None +AlignEscapedNewlines: DontAlign +AlignOperands: Align +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: true +AllowAllConstructorInitializersOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: true +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: AllIfsAndElse +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: true +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: No +AttributeMacros: + - __capability +BasedOnStyle: LLVM +BinPackArguments: true +BinPackParameters: true +BitFieldColonSpacing: Both +BraceWrapping: + AfterCaseLabel: true + AfterClass: true + AfterControlStatement: Always + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterObjCDeclaration: true + AfterStruct: true + AfterUnion: true + AfterExternBlock: true + BeforeCatch: true + BeforeElse: true + BeforeLambdaBody: false + BeforeWhile: true + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAfterJavaFieldAnnotations: false +BreakBeforeBinaryOperators: All +BreakBeforeBraces: Allman +BreakBeforeConceptDeclarations: false +BreakBeforeInheritanceComma: false +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakConstructorInitializersBeforeComma: false +BreakInheritanceList: BeforeColon +BreakStringLiterals: false +ColumnLimit: 80 +CommentPragmas: '' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: false +DeriveLineEnding: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Leave +EmptyLineBeforeAccessModifier: Leave +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: false +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '^(<|"(gtest|gmock|isl|json)/)' + Priority: 3 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 1 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: true +IndentCaseLabels: true +IndentExternBlock: Indent +IndentGotoLabels: false +IndentPPDirectives: AfterHash +IndentRequires: true +IndentWidth: 4 +IndentWrappedFunctionNames: false +InsertTrailingCommas: None +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: true +LambdaBodyIndentation: Signature +Language: Cpp +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 4 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: true +PPIndentWidth: 1 +PackConstructorInitializers: BinPack +PenaltyBreakAssignment: 1 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 1 +PenaltyBreakFirstLessLess: 1 +PenaltyBreakOpenParenthesis: 1 +PenaltyBreakString: 1 +PenaltyBreakTemplateDeclaration: 1 +PenaltyExcessCharacter: 1 +PenaltyIndentedWhitespace: 1 +PenaltyReturnTypeOnItsOwnLine: 1 +PointerAlignment: Right +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: false +RemoveBracesLLVM: false +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 0 +SortIncludes: Never +SortJavaStaticImport: Before +SortUsingDeclarations: false +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterOverloadedOperator: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: Leave +SpacesInCStyleCastParentheses: false +SpacesInConditionalStatement: false +SpacesInContainerLiterals: false +SpacesInLineCommentPrefix: + Minimum: 0 + Maximum: -1 +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Auto +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 4 +UseCRLF: false +UseTab: Always +WhitespaceSensitiveMacros: + - STRINGIZE + - PP_STRINGIZE + - BOOST_PP_STRINGIZE + - NS_SWIFT_NAME + - CF_SWIFT_NAME diff --git a/Makefile b/Makefile index db1c040..c47ebd3 100644 --- a/Makefile +++ b/Makefile @@ -27,14 +27,53 @@ ARDUINO_CLI_CONFIG ?= tools/arduino-cli/arduino-cli.yaml RELEASE_DIR ?= build RELEASE_META ?= $(RELEASE_DIR)/release-meta.json RELEASE_INFO ?= $(RELEASE_DIR)/release-info.json +SRC_DIR ?= src -.PHONY: help release publish-manifest +# Neutralize secondary goals for: make format all | make format +ifeq ($(firstword $(MAKECMDGOALS)),format) + FORMAT_ARG := $(word 2,$(MAKECMDGOALS)) + ifneq ($(FORMAT_ARG),) + $(eval $(FORMAT_ARG):;@:) + endif +endif + +.PHONY: help release publish-manifest format help: @printf '%s\n' \ 'Available targets:' \ ' make release Run the Firmware Release Manager.' \ - ' make publish-manifest Update ota/manifest.json from release-info.json.' + ' make publish-manifest Update ota/manifest.json from release-info.json.' \ + ' make format all Format all .ino/.c/.cpp/.h files under src/.' \ + ' make format Format one file under src/ (basename or src/).' + +format: + @set -euo pipefail; \ + command -v clang-format >/dev/null || { echo 'ERROR: clang-format is required.'; exit 1; }; \ + arg="$(FORMAT_ARG)"; \ + if [ -z "$$arg" ]; then \ + echo 'ERROR: missing format target.'; \ + echo 'Usage: make format all'; \ + echo ' make format '; \ + exit 1; \ + fi; \ + files=(); \ + if [ "$$arg" = "all" ]; then \ + shopt -s nullglob; \ + candidates=("$(SRC_DIR)"/*.ino "$(SRC_DIR)"/*.c "$(SRC_DIR)"/*.cpp "$(SRC_DIR)"/*.h); \ + if [ "$${#candidates[@]}" -eq 0 ]; then echo "ERROR: no source files found in $(SRC_DIR)/."; exit 1; fi; \ + while IFS= read -r f; do files+=("$$f"); done < <(printf '%s\n' "$${candidates[@]}" | LC_ALL=C sort); \ + else \ + candidate="$$arg"; \ + if [ ! -f "$$candidate" ]; then candidate="$(SRC_DIR)/$$(basename "$$arg")"; fi; \ + if [ ! -f "$$candidate" ]; then echo "ERROR: file not found under $(SRC_DIR)/: $$arg"; exit 1; fi; \ + resolved=$$(cd "$(SRC_DIR)" && pwd)/$$(basename "$$candidate"); \ + actual=$$(cd "$$(dirname "$$candidate")" && pwd)/$$(basename "$$candidate"); \ + if [ "$$actual" != "$$resolved" ]; then echo "ERROR: only files under $(SRC_DIR)/ can be formatted: $$arg"; exit 1; fi; \ + files=("$$candidate"); \ + fi; \ + printf '[format] Formatting %s file(s) with clang-format\n' "$${#files[@]}"; \ + clang-format -i -style=file -- "$${files[@]}" release: @set -euo pipefail; printf '\n[release] Starting OTA release manager\n\n' From 17090acadbe175f6ab3b2191edb05e39c04c89b9 Mon Sep 17 00:00:00 2001 From: RomanAlexandroff Date: Mon, 24 Aug 2026 11:47:18 +0200 Subject: [PATCH 2/3] Refactor firmware release manager tooling Move release internals out of the root Makefile so future tools can live under tools/ while preserving the existing user-facing commands. --- .clang-format | 17 +- Makefile | 351 +----------------- tools/firmware_release_manager/Makefile | 85 +++++ .../publish_manifest.cpython-314.pyc | Bin 0 -> 7506 bytes .../__pycache__/release_info.cpython-314.pyc | Bin 0 -> 6453 bytes .../release_preflight.cpython-314.pyc | Bin 0 -> 12575 bytes .../publish_manifest.py | 149 ++++++++ .../firmware_release_manager/release_info.py | 116 ++++++ .../release_preflight.py | 259 +++++++++++++ 9 files changed, 626 insertions(+), 351 deletions(-) create mode 100644 tools/firmware_release_manager/Makefile create mode 100644 tools/firmware_release_manager/__pycache__/publish_manifest.cpython-314.pyc create mode 100644 tools/firmware_release_manager/__pycache__/release_info.cpython-314.pyc create mode 100644 tools/firmware_release_manager/__pycache__/release_preflight.cpython-314.pyc create mode 100644 tools/firmware_release_manager/publish_manifest.py create mode 100644 tools/firmware_release_manager/release_info.py create mode 100644 tools/firmware_release_manager/release_preflight.py diff --git a/.clang-format b/.clang-format index ee4ae89..0d57477 100644 --- a/.clang-format +++ b/.clang-format @@ -1,17 +1,12 @@ -# Derived from Arduino IDE 2's default ClangFormat configuration: -# https://raw.githubusercontent.com/arduino/tooling-project-assets/main/other/clang-format-configuration/.clang-format -# + # Tailored toward The Norm Version 3 formatting (indent, braces, spacing, # line length, etc.) as closely as ClangFormat's option set allows. # -# Important: -# - A custom .clang-format fully overrides Arduino's default rather than -# merging with it. Options below are set explicitly on purpose. -# - ft_ / FT_ prefix naming rules from The Norm are intentionally excluded; -# they do not apply to this project. -# - Rules ClangFormat cannot express still need manual / Norminette checks -# (naming prefixes other than ft_, return parentheses, tab between return -# type and function name, function-size limits, forbidden constructs, etc.). +# ft_ / FT_ prefix naming rules from The Norm are intentionally excluded; +# they do not apply to this project. +# Rules ClangFormat cannot express still need manual / Norminette checks +# (naming prefixes other than ft_, return parentheses, tab between return +# type and function name, function-size limits, forbidden constructs, etc.). --- AccessModifierOffset: -2 AlignAfterOpenBracket: Align diff --git a/Makefile b/Makefile index c47ebd3..8274b3f 100644 --- a/Makefile +++ b/Makefile @@ -1,33 +1,15 @@ - ################################################################## -# Firmware Release Manager for the 42 Smart Cluster Sign Project -# -# Have you just finished working on a new firmware for the Sign -# and now want to upload it to the device? This tool automates -# the whole process. Just call 'make release' in the Terminal, -# fix the issues that the tool points out, if any, and the rest -# will be taken care for you. +# Project Tooling Entry Point for 42 Smart Cluster Sign # -# If you want to speed up the process, send the '/ota' command -# in the Telegram chat or press the OTA button on the physical -# device when you are done with this Firmware Release Manager. +# This root Makefile exposes the user-facing commands. Larger tools +# live under tools/ and are delegated to their own Makefiles so the +# repository root stays readable as the tooling grows. ################################################################## - SHELL := /bin/bash -REPO ?= RomanAlexandroff/42-Smart-Cluster-Sign -MANIFEST ?= ota/manifest.json -CONFIG ?= src/config.h -CREDENTIALS ?= src/credentials.h -SKETCH ?= src/src.ino -LIBRARIES_PATH ?= libraries -ARDUINO_BOARD ?= esp32:esp32:XIAO_ESP32C3:UploadSpeed=230400,CDCOnBoot=default,CPUFreq=160,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=min_spiffs,DebugLevel=debug,EraseFlash=none -ARDUINO_CLI_CONFIG ?= tools/arduino-cli/arduino-cli.yaml -RELEASE_DIR ?= build -RELEASE_META ?= $(RELEASE_DIR)/release-meta.json -RELEASE_INFO ?= $(RELEASE_DIR)/release-info.json SRC_DIR ?= src +FRM_DIR ?= tools/firmware_release_manager # Neutralize secondary goals for: make format all | make format ifeq ($(firstword $(MAKECMDGOALS)),format) @@ -47,6 +29,12 @@ help: ' make format all Format all .ino/.c/.cpp/.h files under src/.' \ ' make format Format one file under src/ (basename or src/).' +release: + @$(MAKE) -C "$(FRM_DIR)" ROOT_DIR="$(CURDIR)" release + +publish-manifest: + @$(MAKE) -C "$(FRM_DIR)" ROOT_DIR="$(CURDIR)" publish-manifest + format: @set -euo pipefail; \ command -v clang-format >/dev/null || { echo 'ERROR: clang-format is required.'; exit 1; }; \ @@ -74,320 +62,3 @@ format: fi; \ printf '[format] Formatting %s file(s) with clang-format\n' "$${#files[@]}"; \ clang-format -i -style=file -- "$${files[@]}" - -release: - @set -euo pipefail; printf '\n[release] Starting OTA release manager\n\n' - @set -euo pipefail; command -v git >/dev/null || { echo 'ERROR: git is required.'; exit 1; } - @set -euo pipefail; command -v gh >/dev/null || { echo 'ERROR: GitHub CLI (gh) is required.'; exit 1; } - @set -euo pipefail; gh auth status -h github.com 2>&1 | grep -q workflow || { echo 'ERROR: GitHub CLI token is missing the workflow scope.'; echo 'Run: gh auth refresh -h github.com -s workflow'; exit 1; } - @set -euo pipefail; command -v arduino-cli >/dev/null || { echo 'ERROR: arduino-cli is required.'; exit 1; } - @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } - @set -euo pipefail; test -f "$(ARDUINO_CLI_CONFIG)" || { echo "ERROR: $(ARDUINO_CLI_CONFIG) does not exist."; exit 1; } - @set -euo pipefail; rm -rf "$(RELEASE_DIR)/ota-release"; rm -f "$(RELEASE_DIR)"/*.bin "$(RELEASE_META)" "$(RELEASE_INFO)" - @set -euo pipefail; RELEASE_PREFLIGHT=$$(awk '/^# BEGIN RELEASE_PREFLIGHT_PY/{flag=1;next}/^# END RELEASE_PREFLIGHT_PY/{flag=0}flag{sub(/^# ?/,"");print}' Makefile); python3 -c "$$RELEASE_PREFLIGHT" "$(MANIFEST)" "$(CONFIG)" "$(CREDENTIALS)" "$(RELEASE_META)" - @set -euo pipefail; \ - TAG=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["RELEASE_TAG"])' "$(RELEASE_META)"); \ - LOCAL_TAG_EXISTS=0; REMOTE_TAG_EXISTS=0; RELEASE_EXISTS=0; \ - if git rev-parse -q --verify "refs/tags/$$TAG" >/dev/null; then LOCAL_TAG_EXISTS=1; fi; \ - if git ls-remote --exit-code --tags origin "refs/tags/$$TAG" >/dev/null 2>&1; then REMOTE_TAG_EXISTS=1; fi; \ - if gh release view "$$TAG" --repo "$(REPO)" >/dev/null 2>&1; then RELEASE_EXISTS=1; fi; \ - if [ "$$LOCAL_TAG_EXISTS" = "1" ] || [ "$$REMOTE_TAG_EXISTS" = "1" ] || [ "$$RELEASE_EXISTS" = "1" ]; then \ - echo; \ - echo "Release identity $$TAG already exists:"; \ - if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then echo " - local Git tag"; fi; \ - if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then echo " - remote Git tag"; fi; \ - if [ "$$RELEASE_EXISTS" = "1" ]; then echo " - GitHub Release"; fi; \ - echo; \ - printf "Delete the existing release identity and reuse $$TAG? [y/N] "; \ - read -r answer; \ - case "$$answer" in \ - y|Y|yes|YES) ;; \ - *) echo "Release process aborted."; exit 1 ;; \ - esac; \ - if [ "$$RELEASE_EXISTS" = "1" ]; then gh release delete "$$TAG" --repo "$(REPO)" --yes; fi; \ - if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then git tag -d "$$TAG"; fi; \ - if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then git push origin ":refs/tags/$$TAG"; fi; \ - fi - @set -euo pipefail; mkdir -p "$(RELEASE_DIR)"; printf '\n[release] Compiling firmware locally\n\n'; arduino-cli --config-file "$(ARDUINO_CLI_CONFIG)" compile --fqbn "$(ARDUINO_BOARD)" --libraries "$(LIBRARIES_PATH)" --output-dir "$(RELEASE_DIR)" --build-property build.partitions=min_spiffs --build-property upload.maximum_size=1966080 --verbose "$(SKETCH)" - @set -euo pipefail; FIRMWARE_SOURCE=$$(python3 -c 'import pathlib,sys; root=pathlib.Path(sys.argv[1]); firmware_name=pathlib.Path(sys.argv[2]).name + ".bin"; matches=list(root.glob(firmware_name)); sys.exit(f"ERROR: expected firmware binary {firmware_name} was not found in {root}") if len(matches) != 1 else print(matches[0])' "$(RELEASE_DIR)" "$(SKETCH)"); FIRMWARE_ASSET=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["FIRMWARE_ASSET"])' "$(RELEASE_META)"); cp "$$FIRMWARE_SOURCE" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" - @set -euo pipefail; FIRMWARE_ASSET=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["FIRMWARE_ASSET"])' "$(RELEASE_META)"); FIRMWARE_ASSET_PATH="$(RELEASE_DIR)/$$FIRMWARE_ASSET"; SHA256=$$(python3 -c 'import hashlib,sys; h=hashlib.sha256(); f=open(sys.argv[1],"rb"); [h.update(c) for c in iter(lambda:f.read(1048576), b"")]; print(h.hexdigest())' "$$FIRMWARE_ASSET_PATH"); SIZE=$$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).stat().st_size)' "$$FIRMWARE_ASSET_PATH"); python3 -c 'import json,pathlib,sys; info=json.load(open(sys.argv[1])); info["SHA-256"]=sys.argv[3]; info["SIZE"]=int(sys.argv[4]); pathlib.Path(sys.argv[2]).write_text(json.dumps(info, indent=2)+"\n"); print(f"[release] Wrote release contract to {sys.argv[2]}")' "$(RELEASE_META)" "$(RELEASE_INFO)" "$$SHA256" "$$SIZE" - @set -euo pipefail; TAG=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["RELEASE_TAG"])' "$(RELEASE_META)"); DEVICE_NAME=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["DEVICE_NAME"])' "$(RELEASE_META)"); SOFTWARE_VERSION=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["SOFTWARE_VERSION"])' "$(RELEASE_META)"); FIRMWARE_ASSET=$$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["FIRMWARE_ASSET"])' "$(RELEASE_META)"); printf '\n[release] Creating GitHub Release %s\n\n' "$$TAG"; gh release create "$$TAG" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" "$(RELEASE_INFO)" --repo "$(REPO)" --target "$$(git rev-parse HEAD)" --title "$$DEVICE_NAME $$SOFTWARE_VERSION" --notes "Automated OTA firmware release for $$DEVICE_NAME, version $$SOFTWARE_VERSION." - @set -euo pipefail; rm -f "$(RELEASE_INFO)"; printf '\n[release] Release complete. Local release-info.json was removed after upload.\n' - -publish-manifest: - @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } - @set -euo pipefail; test -n "$(RELEASE_INFO)" || { echo 'ERROR: RELEASE_INFO is required.'; exit 1; } - @set -euo pipefail; test -f "$(RELEASE_INFO)" || { echo "ERROR: $(RELEASE_INFO) does not exist."; exit 1; } - @set -euo pipefail; awk '/^# BEGIN PUBLISH_MANIFEST_PY/{flag=1;next}/^# END PUBLISH_MANIFEST_PY/{flag=0}flag{sub(/^# ?/,"");print}' Makefile | python3 - "$(MANIFEST)" "$(RELEASE_INFO)" "$(RELEASE_TAG)" "$(REPO)" - -# BEGIN RELEASE_PREFLIGHT_PY -# import json -# import pathlib -# import re -# import subprocess -# import sys -# -# manifest_path = pathlib.Path(sys.argv[1]) -# config_path = pathlib.Path(sys.argv[2]) -# credentials_path = pathlib.Path(sys.argv[3]) -# meta_path = pathlib.Path(sys.argv[4]) -# -# def fail(message): -# print(f"\nERROR: {message}") -# print("Release process aborted.") -# sys.exit(1) -# -# def git_output(*args): -# return subprocess.check_output(["git", *args], text=True).strip() -# -# def parse_define(path, name, quoted): -# pattern = rf"^\s*#\s*define\s+{re.escape(name)}\s+" -# if quoted: -# pattern += r'"([^"]*)"' -# else: -# pattern += r'([^\s/]+)' -# regex = re.compile(pattern) -# for line in path.read_text().splitlines(): -# match = regex.search(line) -# if match: -# return match.group(1).strip() -# fail(f"{name} was not found in {path}") -# -# def parse_active_credential(path, name): -# regex = re.compile(rf"^\s*#\s*define\s+{re.escape(name)}\s+\"([^\"]*)\"") -# for line in path.read_text().splitlines(): -# if line.lstrip().startswith("//"): -# continue -# match = regex.search(line) -# if match: -# return match.group(1) -# fail(f"{name} was not found in active credentials") -# -# def version_parts(value): -# parts = [] -# for part in re.split(r"[^\d]+", str(value)): -# if part: -# parts.append(int(part)) -# return parts or [0] -# -# def compare_versions(left, right): -# left_parts = version_parts(left) -# right_parts = version_parts(right) -# width = max(len(left_parts), len(right_parts)) -# left_parts.extend([0] * (width - len(left_parts))) -# right_parts.extend([0] * (width - len(right_parts))) -# return (left_parts > right_parts) - (left_parts < right_parts) -# -# if not manifest_path.exists(): -# fail(f"{manifest_path} does not exist") -# if not config_path.exists(): -# fail(f"{config_path} does not exist") -# if not credentials_path.exists(): -# fail(f"{credentials_path} does not exist") -# -# status = git_output("status", "--porcelain", "--untracked-files=normal") -# if status: -# print(status) -# fail("Git working tree is not clean") -# -# subprocess.run(["git", "fetch", "--quiet", "origin"], check=False) -# try: -# upstream = git_output("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") -# except subprocess.CalledProcessError: -# fail("Current branch has no upstream. Push the branch and try again") -# local_sha = git_output("rev-parse", "HEAD") -# upstream_sha = git_output("rev-parse", upstream) -# if local_sha != upstream_sha: -# print(f"\nCurrent branch : {git_output('branch', '--show-current')}") -# print(f"Upstream branch: {upstream}") -# print(f"Local HEAD : {local_sha}") -# print(f"Upstream HEAD : {upstream_sha}") -# fail("Current HEAD is not pushed to GitHub. Push the branch and try again") -# -# tracked_credentials = subprocess.run( -# ["git", "ls-files", "--error-unmatch", str(credentials_path)], -# stdout=subprocess.DEVNULL, -# stderr=subprocess.DEVNULL, -# ) -# if tracked_credentials.returncode == 0: -# fail(f"{credentials_path} is tracked by Git; credentials must stay local") -# -# manifest = json.loads(manifest_path.read_text()) -# devices = manifest.get("devices") -# if not isinstance(devices, dict) or not devices: -# fail("manifest does not contain any devices") -# -# device_names = list(devices.keys()) -# print("Found devices:\n") -# for index, name in enumerate(device_names, start=1): -# print(f"[{index}] {name}") -# -# selection = input("\nSelect exactly one device: ").strip() -# if not selection.isdigit(): -# fail("Device selection must be a number") -# selection_index = int(selection) -# if selection_index < 1 or selection_index > len(device_names): -# fail("Device selection is out of range") -# -# selected_device = device_names[selection_index - 1] -# device_entry = devices[selected_device] -# source_device = parse_define(config_path, "DEVICE_NAME", quoted=True) -# source_version = parse_define(config_path, "SOFTWARE_VERSION", quoted=False) -# wifi_ssid = parse_active_credential(credentials_path, "WIFI_SSID") -# wifi_password = parse_active_credential(credentials_path, "WIFI_PASSWORD") -# manifest_version = str(device_entry.get("version", "")) -# manifest_url = str(device_entry.get("url", "")) -# manifest_sha256 = str(device_entry.get("sha256", "")) -# manifest_size = device_entry.get("size", "") -# manifest_enabled = device_entry.get("enabled", "") -# -# if selected_device != source_device: -# print(f"\nSelected device : {selected_device}") -# print(f"DEVICE_NAME in the source code: {source_device}") -# fail("Device mismatch. Change DEVICE_NAME in config.h and try again") -# -# if compare_versions(source_version, manifest_version) <= 0: -# print("\nWARNING: Source firmware version is not newer than the published manifest version.") -# -# tag = f"v{source_version}" -# asset_device_name = source_device.replace(" ", "_") -# asset_name = f"firmware_{asset_device_name}_v.{source_version}.bin" -# current_branch = git_output("branch", "--show-current") -# -# print("\n==================================================") -# print("\nDevice\n------") -# print(f"Manifest device name : {selected_device}") -# print(f"Source code DEVICE_NAME: {source_device}") -# print("\nVersions\n--------") -# print(f"Manifest SW number : {manifest_version}") -# print(f"Source code SW number: {source_version}") -# print("\nWiFi\n----") -# print(f"SSID : {wifi_ssid}") -# print(f"Password : {wifi_password}") -# print("\nManifest\n--------") -# print(f"URL : {manifest_url}") -# print(f"SHA-256 : {manifest_sha256}") -# print(f"Size : {manifest_size} bytes") -# print(f"OTA enabled : {manifest_enabled}") -# print("\nGit\n---") -# print(f"Branch : {current_branch}") -# print("Working tree : Clean") -# print("\nRelease\n-------") -# print(f"Tag : {tag}") -# print(f"Firmware asset : {asset_name}") -# print("\n==================================================\n") -# -# answer = input("Continue? [y/N] ").strip().lower() -# if answer not in {"y", "yes"}: -# fail("User declined release confirmation") -# -# meta_path.parent.mkdir(parents=True, exist_ok=True) -# meta_path.write_text(json.dumps({ -# "DEVICE_NAME": source_device, -# "SOFTWARE_VERSION": source_version, -# "MANIFEST_VERSION": manifest_version, -# "FIRMWARE_ASSET": asset_name, -# "RELEASE_TAG": tag, -# }, indent=2) + "\n") -# print(f"\n[release] Wrote local metadata to {meta_path}") -# END RELEASE_PREFLIGHT_PY - -# BEGIN PUBLISH_MANIFEST_PY -# import json -# import pathlib -# import re -# import sys -# import urllib.parse -# -# manifest_path = pathlib.Path(sys.argv[1]) -# release_info_path = pathlib.Path(sys.argv[2]) -# release_tag_arg = sys.argv[3].strip() -# repo = sys.argv[4].strip() -# -# def fail(message): -# print(f"ERROR: {message}", file=sys.stderr) -# sys.exit(1) -# -# def find_matching_brace(text, start_index): -# depth = 0 -# in_string = False -# escaped = False -# for index in range(start_index, len(text)): -# char = text[index] -# if in_string: -# if escaped: -# escaped = False -# elif char == "\\": -# escaped = True -# elif char == '"': -# in_string = False -# continue -# if char == '"': -# in_string = True -# elif char == "{": -# depth += 1 -# elif char == "}": -# depth -= 1 -# if depth == 0: -# return index -# fail("could not find matching manifest device block brace") -# -# def json_value(value): -# return json.dumps(value, ensure_ascii=False) -# -# def replace_field(block, field, value): -# pattern = re.compile( -# rf'("{re.escape(field)}"\s*:\s*)(?:"(?:\\.|[^"\\])*"|\d+|true|false|null)' -# ) -# updated, count = pattern.subn(lambda match: match.group(1) + json_value(value), block, count=1) -# if count != 1: -# fail(f"field {field} was not found exactly once in selected manifest block") -# return updated -# -# info = json.loads(release_info_path.read_text()) -# device = info.get("DEVICE_NAME") -# version = info.get("SOFTWARE_VERSION") -# sha256 = info.get("SHA-256") -# size = info.get("SIZE") -# asset_name = info.get("FIRMWARE_ASSET") -# release_tag = release_tag_arg or info.get("RELEASE_TAG") -# -# if not all([device, version, sha256, size, asset_name, release_tag]): -# fail("release-info.json is missing required metadata") -# -# firmware_url = ( -# f"https://github.com/{repo}/releases/download/" -# f"{urllib.parse.quote(str(release_tag), safe='')}/" -# f"{urllib.parse.quote(str(asset_name), safe='')}" -# ) -# -# text = manifest_path.read_text() -# manifest_before = json.loads(text) -# devices = manifest_before.get("devices", {}) -# if device not in devices: -# fail(f"device {device!r} was not found in manifest") -# enabled_before = devices[device].get("enabled") -# -# device_match = re.search(rf'"{re.escape(device)}"\s*:\s*\{{', text) -# if not device_match: -# fail(f"device block for {device!r} was not found in manifest text") -# block_start = text.find("{", device_match.start()) -# block_end = find_matching_brace(text, block_start) -# block = text[block_start:block_end + 1] -# block = replace_field(block, "version", str(version)) -# block = replace_field(block, "url", firmware_url) -# block = replace_field(block, "sha256", str(sha256)) -# block = replace_field(block, "size", int(size)) -# updated_text = text[:block_start] + block + text[block_end + 1:] -# -# manifest_after = json.loads(updated_text) -# enabled_after = manifest_after["devices"][device].get("enabled") -# if enabled_before != enabled_after: -# fail("manifest enabled field changed unexpectedly") -# if set(manifest_before.get("devices", {}).keys()) != set(manifest_after.get("devices", {}).keys()): -# fail("manifest device set changed unexpectedly") -# -# manifest_path.write_text(updated_text) -# print(f"Updated {manifest_path} for {device} {version}") -# print(f"Firmware URL: {firmware_url}") -# END PUBLISH_MANIFEST_PY diff --git a/tools/firmware_release_manager/Makefile b/tools/firmware_release_manager/Makefile new file mode 100644 index 0000000..c788517 --- /dev/null +++ b/tools/firmware_release_manager/Makefile @@ -0,0 +1,85 @@ +################################################################## +# Firmware Release Manager for the 42 Smart Cluster Sign Project +# +# This Makefile owns the release-manager internals. It is normally +# called by the root Makefile, but it can also be run directly from +# this folder because all project paths are resolved from ROOT_DIR. +# +# If you want to speed up the OTA flow, send the '/ota' command in +# the Telegram chat or press the OTA button on the physical device +# when this Firmware Release Manager is done. +################################################################## + +SHELL := /bin/bash + +ROOT_DIR ?= $(abspath ../..) +SCRIPT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + +REPO ?= RomanAlexandroff/42-Smart-Cluster-Sign +MANIFEST ?= $(ROOT_DIR)/ota/manifest.json +CONFIG ?= $(ROOT_DIR)/src/config.h +CREDENTIALS ?= $(ROOT_DIR)/src/credentials.h +SKETCH ?= $(ROOT_DIR)/src/src.ino +LIBRARIES_PATH ?= $(ROOT_DIR)/libraries +ARDUINO_BOARD ?= esp32:esp32:XIAO_ESP32C3:UploadSpeed=230400,CDCOnBoot=default,CPUFreq=160,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=min_spiffs,DebugLevel=debug,EraseFlash=none +ARDUINO_CLI_CONFIG ?= $(ROOT_DIR)/tools/arduino-cli/arduino-cli.yaml +RELEASE_DIR ?= $(ROOT_DIR)/build +RELEASE_META ?= $(RELEASE_DIR)/release-meta.json +RELEASE_INFO ?= $(RELEASE_DIR)/release-info.json + +RELEASE_PREFLIGHT := $(SCRIPT_DIR)/release_preflight.py +RELEASE_INFO_TOOL := $(SCRIPT_DIR)/release_info.py +PUBLISH_MANIFEST_TOOL := $(SCRIPT_DIR)/publish_manifest.py + +.PHONY: help release publish-manifest + +help: + @printf '%s\n' \ + 'Firmware Release Manager targets:' \ + ' make release Build and publish an OTA firmware release.' \ + ' make publish-manifest Update ota/manifest.json from release-info.json.' + +release: + @set -euo pipefail; printf '\n[release] Starting OTA release manager\n\n' + @set -euo pipefail; command -v git >/dev/null || { echo 'ERROR: git is required.'; exit 1; } + @set -euo pipefail; command -v gh >/dev/null || { echo 'ERROR: GitHub CLI (gh) is required.'; exit 1; } + @set -euo pipefail; gh auth status -h github.com 2>&1 | grep -q workflow || { echo 'ERROR: GitHub CLI token is missing the workflow scope.'; echo 'Run: gh auth refresh -h github.com -s workflow'; exit 1; } + @set -euo pipefail; command -v arduino-cli >/dev/null || { echo 'ERROR: arduino-cli is required.'; exit 1; } + @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } + @set -euo pipefail; test -f "$(ARDUINO_CLI_CONFIG)" || { echo "ERROR: $(ARDUINO_CLI_CONFIG) does not exist."; exit 1; } + @set -euo pipefail; rm -rf "$(RELEASE_DIR)/ota-release"; rm -f "$(RELEASE_DIR)"/*.bin "$(RELEASE_META)" "$(RELEASE_INFO)" + @set -euo pipefail; cd "$(ROOT_DIR)" && python3 "$(RELEASE_PREFLIGHT)" "$(MANIFEST)" "$(CONFIG)" "$(CREDENTIALS)" "$(RELEASE_META)" + @set -euo pipefail; \ + TAG=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" RELEASE_TAG); \ + LOCAL_TAG_EXISTS=0; REMOTE_TAG_EXISTS=0; RELEASE_EXISTS=0; \ + if git -C "$(ROOT_DIR)" rev-parse -q --verify "refs/tags/$$TAG" >/dev/null; then LOCAL_TAG_EXISTS=1; fi; \ + if git -C "$(ROOT_DIR)" ls-remote --exit-code --tags origin "refs/tags/$$TAG" >/dev/null 2>&1; then REMOTE_TAG_EXISTS=1; fi; \ + if gh release view "$$TAG" --repo "$(REPO)" >/dev/null 2>&1; then RELEASE_EXISTS=1; fi; \ + if [ "$$LOCAL_TAG_EXISTS" = "1" ] || [ "$$REMOTE_TAG_EXISTS" = "1" ] || [ "$$RELEASE_EXISTS" = "1" ]; then \ + echo; \ + echo "Release identity $$TAG already exists:"; \ + if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then echo " - local Git tag"; fi; \ + if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then echo " - remote Git tag"; fi; \ + if [ "$$RELEASE_EXISTS" = "1" ]; then echo " - GitHub Release"; fi; \ + echo; \ + printf "Delete the existing release identity and reuse $$TAG? [y/N] "; \ + read -r answer; \ + case "$$answer" in \ + y|Y|yes|YES) ;; \ + *) echo "Release process aborted."; exit 1 ;; \ + esac; \ + if [ "$$RELEASE_EXISTS" = "1" ]; then gh release delete "$$TAG" --repo "$(REPO)" --yes; fi; \ + if [ "$$LOCAL_TAG_EXISTS" = "1" ]; then git -C "$(ROOT_DIR)" tag -d "$$TAG"; fi; \ + if [ "$$REMOTE_TAG_EXISTS" = "1" ]; then git -C "$(ROOT_DIR)" push origin ":refs/tags/$$TAG"; fi; \ + fi + @set -euo pipefail; mkdir -p "$(RELEASE_DIR)"; printf '\n[release] Compiling firmware locally\n\n'; arduino-cli --config-file "$(ARDUINO_CLI_CONFIG)" compile --fqbn "$(ARDUINO_BOARD)" --libraries "$(LIBRARIES_PATH)" --output-dir "$(RELEASE_DIR)" --build-property build.partitions=min_spiffs --build-property upload.maximum_size=1966080 --verbose "$(SKETCH)" + @set -euo pipefail; FIRMWARE_SOURCE=$$(python3 "$(RELEASE_INFO_TOOL)" find-firmware "$(RELEASE_DIR)" "$(SKETCH)"); FIRMWARE_ASSET=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" FIRMWARE_ASSET); cp "$$FIRMWARE_SOURCE" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" + @set -euo pipefail; FIRMWARE_ASSET=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" FIRMWARE_ASSET); FIRMWARE_ASSET_PATH="$(RELEASE_DIR)/$$FIRMWARE_ASSET"; python3 "$(RELEASE_INFO_TOOL)" write-release-info "$(RELEASE_META)" "$(RELEASE_INFO)" "$$FIRMWARE_ASSET_PATH" + @set -euo pipefail; TAG=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" RELEASE_TAG); DEVICE_NAME=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" DEVICE_NAME); SOFTWARE_VERSION=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" SOFTWARE_VERSION); FIRMWARE_ASSET=$$(python3 "$(RELEASE_INFO_TOOL)" read-meta "$(RELEASE_META)" FIRMWARE_ASSET); printf '\n[release] Creating GitHub Release %s\n\n' "$$TAG"; gh release create "$$TAG" "$(RELEASE_DIR)/$$FIRMWARE_ASSET" "$(RELEASE_INFO)" --repo "$(REPO)" --target "$$(git -C "$(ROOT_DIR)" rev-parse HEAD)" --title "$$DEVICE_NAME $$SOFTWARE_VERSION" --notes "Automated OTA firmware release for $$DEVICE_NAME, version $$SOFTWARE_VERSION." + @set -euo pipefail; rm -f "$(RELEASE_INFO)"; printf '\n[release] Release complete. Local release-info.json was removed after upload.\n' + +publish-manifest: + @set -euo pipefail; command -v python3 >/dev/null || { echo 'ERROR: python3 is required.'; exit 1; } + @set -euo pipefail; test -n "$(RELEASE_INFO)" || { echo 'ERROR: RELEASE_INFO is required.'; exit 1; } + @set -euo pipefail; test -f "$(RELEASE_INFO)" || { echo "ERROR: $(RELEASE_INFO) does not exist."; exit 1; } + @set -euo pipefail; python3 "$(PUBLISH_MANIFEST_TOOL)" "$(MANIFEST)" "$(RELEASE_INFO)" "$(RELEASE_TAG)" "$(REPO)" diff --git a/tools/firmware_release_manager/__pycache__/publish_manifest.cpython-314.pyc b/tools/firmware_release_manager/__pycache__/publish_manifest.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b15e2300312c86c1ee87e2f767fd5f90d1a4cf7f GIT binary patch literal 7506 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 BTlxS1 literal 0 HcmV?d00001 diff --git a/tools/firmware_release_manager/__pycache__/release_info.cpython-314.pyc b/tools/firmware_release_manager/__pycache__/release_info.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b7e2ac5fbc4b7992d3551829669fd4b6b380126b GIT binary patch literal 6453 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 " + ) + + manifest_path = pathlib.Path(argv[1]) + release_info_path = pathlib.Path(argv[2]) + release_tag_arg = argv[3].strip() + repo = argv[4].strip() + + update_manifest(manifest_path, release_info_path, release_tag_arg, repo) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/tools/firmware_release_manager/release_info.py b/tools/firmware_release_manager/release_info.py new file mode 100644 index 0000000..dbbccf5 --- /dev/null +++ b/tools/firmware_release_manager/release_info.py @@ -0,0 +1,116 @@ +"""Release metadata helpers for the Firmware Release Manager. + +The Makefile uses this small CLI for operations that were previously +implemented as inline `python3 -c` snippets: reading release metadata, +finding the compiled firmware binary, and writing the upload contract. +""" + +import argparse +import hashlib +import json +import pathlib +import sys + + +def fail(message): + """Print an error and return a non-zero exit code.""" + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + +def load_json(path): + """Load JSON from a path with a consistent error message.""" + try: + return json.loads(path.read_text()) + except FileNotFoundError: + fail(f"{path} does not exist") + except json.JSONDecodeError as error: + fail(f"{path} is not valid JSON: {error}") + + +def read_meta(args): + """Print one key from release-meta.json.""" + meta_path = pathlib.Path(args.meta) + info = load_json(meta_path) + try: + print(info[args.key]) + except KeyError: + fail(f"{args.key} was not found in {meta_path}") + + +def find_firmware(args): + """Find the firmware binary emitted by arduino-cli for the sketch.""" + release_dir = pathlib.Path(args.release_dir) + firmware_name = pathlib.Path(args.sketch).name + ".bin" + matches = list(release_dir.glob(firmware_name)) + if len(matches) != 1: + fail(f"expected firmware binary {firmware_name} was not found in {release_dir}") + print(matches[0]) + + +def sha256_file(path): + """Calculate a firmware file SHA-256 without loading it all at once.""" + hash_object = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1048576), b""): + hash_object.update(chunk) + return hash_object.hexdigest() + + +def write_release_info(args): + """Write release-info.json with firmware checksum and size.""" + meta_path = pathlib.Path(args.meta) + output_path = pathlib.Path(args.output) + firmware_path = pathlib.Path(args.firmware) + + info = load_json(meta_path) + if not firmware_path.exists(): + fail(f"{firmware_path} does not exist") + + info["SHA-256"] = sha256_file(firmware_path) + info["SIZE"] = firmware_path.stat().st_size + output_path.write_text(json.dumps(info, indent=2) + "\n") + print(f"[release] Wrote release contract to {output_path}") + + +def build_parser(): + """Create the CLI parser used by the Makefile.""" + parser = argparse.ArgumentParser( + description="Firmware Release Manager metadata helper." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + read_parser = subparsers.add_parser( + "read-meta", help="print a key from release-meta.json" + ) + read_parser.add_argument("meta") + read_parser.add_argument("key") + read_parser.set_defaults(func=read_meta) + + find_parser = subparsers.add_parser( + "find-firmware", help="locate the compiled firmware binary" + ) + find_parser.add_argument("release_dir") + find_parser.add_argument("sketch") + find_parser.set_defaults(func=find_firmware) + + write_parser = subparsers.add_parser( + "write-release-info", help="write release-info.json" + ) + write_parser.add_argument("meta") + write_parser.add_argument("output") + write_parser.add_argument("firmware") + write_parser.set_defaults(func=write_release_info) + + return parser + + +def main(): + """Dispatch one release-info helper command.""" + parser = build_parser() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tools/firmware_release_manager/release_preflight.py b/tools/firmware_release_manager/release_preflight.py new file mode 100644 index 0000000..a1aaa06 --- /dev/null +++ b/tools/firmware_release_manager/release_preflight.py @@ -0,0 +1,259 @@ +"""Preflight checks for the Firmware Release Manager. + +This script preserves the release validation that used to be embedded in +the root Makefile: it checks the local Git state, validates the selected +device against source configuration, shows the release summary, and writes +the local release metadata consumed by later Makefile steps. +""" + +import json +import pathlib +import re +import subprocess +import sys + + +def fail(message): + """Print a release-style error and stop the release flow.""" + print(f"\nERROR: {message}") + print("Release process aborted.") + sys.exit(1) + + +def git_output(*args): + """Return stdout from a Git command run in the repository root.""" + return subprocess.check_output(["git", *args], text=True).strip() + + +def parse_define(path, name, quoted): + """Read a #define value from an Arduino/C header file.""" + pattern = rf"^\s*#\s*define\s+{re.escape(name)}\s+" + if quoted: + pattern += r'"([^"]*)"' + else: + pattern += r"([^\s/]+)" + regex = re.compile(pattern) + for line in path.read_text().splitlines(): + match = regex.search(line) + if match: + return match.group(1).strip() + fail(f"{name} was not found in {path}") + + +def parse_active_credential(path, name): + """Read an uncommented credential definition from credentials.h.""" + regex = re.compile(rf'^\s*#\s*define\s+{re.escape(name)}\s+"([^"]*)"') + for line in path.read_text().splitlines(): + if line.lstrip().startswith("//"): + continue + match = regex.search(line) + if match: + return match.group(1) + fail(f"{name} was not found in active credentials") + + +def version_parts(value): + """Split a version string into comparable integer chunks.""" + parts = [] + for part in re.split(r"[^\d]+", str(value)): + if part: + parts.append(int(part)) + return parts or [0] + + +def compare_versions(left, right): + """Compare version-ish strings using their numeric parts.""" + left_parts = version_parts(left) + right_parts = version_parts(right) + width = max(len(left_parts), len(right_parts)) + left_parts.extend([0] * (width - len(left_parts))) + right_parts.extend([0] * (width - len(right_parts))) + return (left_parts > right_parts) - (left_parts < right_parts) + + +def validate_inputs(manifest_path, config_path, credentials_path): + """Fail early if the release inputs are missing.""" + if not manifest_path.exists(): + fail(f"{manifest_path} does not exist") + if not config_path.exists(): + fail(f"{config_path} does not exist") + if not credentials_path.exists(): + fail(f"{credentials_path} does not exist") + + +def validate_git_state(credentials_path): + """Ensure the release starts from a clean, pushed working tree.""" + status = git_output("status", "--porcelain", "--untracked-files=normal") + if status: + print(status) + fail("Git working tree is not clean") + + subprocess.run(["git", "fetch", "--quiet", "origin"], check=False) + try: + upstream = git_output( + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}" + ) + except subprocess.CalledProcessError: + fail("Current branch has no upstream. Push the branch and try again") + + local_sha = git_output("rev-parse", "HEAD") + upstream_sha = git_output("rev-parse", upstream) + if local_sha != upstream_sha: + print(f"\nCurrent branch : {git_output('branch', '--show-current')}") + print(f"Upstream branch: {upstream}") + print(f"Local HEAD : {local_sha}") + print(f"Upstream HEAD : {upstream_sha}") + fail("Current HEAD is not pushed to GitHub. Push the branch and try again") + + tracked_credentials = subprocess.run( + ["git", "ls-files", "--error-unmatch", str(credentials_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if tracked_credentials.returncode == 0: + fail(f"{credentials_path} is tracked by Git; credentials must stay local") + + +def select_device(devices): + """Prompt the user to pick exactly one manifest device.""" + device_names = list(devices.keys()) + print("Found devices:\n") + for index, name in enumerate(device_names, start=1): + print(f"[{index}] {name}") + + selection = input("\nSelect exactly one device: ").strip() + if not selection.isdigit(): + fail("Device selection must be a number") + selection_index = int(selection) + if selection_index < 1 or selection_index > len(device_names): + fail("Device selection is out of range") + return device_names[selection_index - 1] + + +def print_summary( + selected_device, + source_device, + manifest_version, + source_version, + wifi_ssid, + wifi_password, + manifest_url, + manifest_sha256, + manifest_size, + manifest_enabled, + tag, + asset_name, +): + """Show the same confirmation summary as the original Makefile flow.""" + current_branch = git_output("branch", "--show-current") + + print("\n==================================================") + print("\nDevice\n------") + print(f"Manifest device name : {selected_device}") + print(f"Source code DEVICE_NAME: {source_device}") + print("\nVersions\n--------") + print(f"Manifest SW number : {manifest_version}") + print(f"Source code SW number: {source_version}") + print("\nWiFi\n----") + print(f"SSID : {wifi_ssid}") + print(f"Password : {wifi_password}") + print("\nManifest\n--------") + print(f"URL : {manifest_url}") + print(f"SHA-256 : {manifest_sha256}") + print(f"Size : {manifest_size} bytes") + print(f"OTA enabled : {manifest_enabled}") + print("\nGit\n---") + print(f"Branch : {current_branch}") + print("Working tree : Clean") + print("\nRelease\n-------") + print(f"Tag : {tag}") + print(f"Firmware asset : {asset_name}") + print("\n==================================================\n") + + +def main(argv): + """Run release preflight and write release-meta.json.""" + if len(argv) != 5: + fail( + "usage: release_preflight.py " + " " + ) + + manifest_path = pathlib.Path(argv[1]) + config_path = pathlib.Path(argv[2]) + credentials_path = pathlib.Path(argv[3]) + meta_path = pathlib.Path(argv[4]) + + validate_inputs(manifest_path, config_path, credentials_path) + validate_git_state(credentials_path) + + manifest = json.loads(manifest_path.read_text()) + devices = manifest.get("devices") + if not isinstance(devices, dict) or not devices: + fail("manifest does not contain any devices") + + selected_device = select_device(devices) + device_entry = devices[selected_device] + source_device = parse_define(config_path, "DEVICE_NAME", quoted=True) + source_version = parse_define(config_path, "SOFTWARE_VERSION", quoted=False) + wifi_ssid = parse_active_credential(credentials_path, "WIFI_SSID") + wifi_password = parse_active_credential(credentials_path, "WIFI_PASSWORD") + manifest_version = str(device_entry.get("version", "")) + manifest_url = str(device_entry.get("url", "")) + manifest_sha256 = str(device_entry.get("sha256", "")) + manifest_size = device_entry.get("size", "") + manifest_enabled = device_entry.get("enabled", "") + + if selected_device != source_device: + print(f"\nSelected device : {selected_device}") + print(f"DEVICE_NAME in the source code: {source_device}") + fail("Device mismatch. Change DEVICE_NAME in config.h and try again") + + if compare_versions(source_version, manifest_version) <= 0: + print( + "\nWARNING: Source firmware version is not newer than " + "the published manifest version." + ) + + tag = f"v{source_version}" + asset_device_name = source_device.replace(" ", "_") + asset_name = f"firmware_{asset_device_name}_v.{source_version}.bin" + + print_summary( + selected_device, + source_device, + manifest_version, + source_version, + wifi_ssid, + wifi_password, + manifest_url, + manifest_sha256, + manifest_size, + manifest_enabled, + tag, + asset_name, + ) + + answer = input("Continue? [y/N] ").strip().lower() + if answer not in {"y", "yes"}: + fail("User declined release confirmation") + + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text( + json.dumps( + { + "DEVICE_NAME": source_device, + "SOFTWARE_VERSION": source_version, + "MANIFEST_VERSION": manifest_version, + "FIRMWARE_ASSET": asset_name, + "RELEASE_TAG": tag, + }, + indent=2, + ) + + "\n" + ) + print(f"\n[release] Wrote local metadata to {meta_path}") + + +if __name__ == "__main__": + main(sys.argv) From eb5db8dc83e9b8791dfa5751072b7a4e5955c77d Mon Sep 17 00:00:00 2001 From: RomanAlexandroff Date: Mon, 24 Aug 2026 11:47:34 +0200 Subject: [PATCH 3/3] Ignore generated Python bytecode Keep release manager verification artifacts out of version control. --- .gitignore | 4 ++++ .../publish_manifest.cpython-314.pyc | Bin 7506 -> 0 bytes .../__pycache__/release_info.cpython-314.pyc | Bin 6453 -> 0 bytes .../release_preflight.cpython-314.pyc | Bin 12575 -> 0 bytes 4 files changed, 4 insertions(+) delete mode 100644 tools/firmware_release_manager/__pycache__/publish_manifest.cpython-314.pyc delete mode 100644 tools/firmware_release_manager/__pycache__/release_info.cpython-314.pyc delete mode 100644 tools/firmware_release_manager/__pycache__/release_preflight.cpython-314.pyc diff --git a/.gitignore b/.gitignore index e6b83ef..6e09f85 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,10 @@ release-info.json *.idb *.pdb +# Python bytecode +__pycache__/ +*.pyc + # Kernel Module Compile Results *.mod* *.cmd diff --git a/tools/firmware_release_manager/__pycache__/publish_manifest.cpython-314.pyc b/tools/firmware_release_manager/__pycache__/publish_manifest.cpython-314.pyc deleted file mode 100644 index b15e2300312c86c1ee87e2f767fd5f90d1a4cf7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7506 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 BTlxS1 diff --git a/tools/firmware_release_manager/__pycache__/release_info.cpython-314.pyc b/tools/firmware_release_manager/__pycache__/release_info.cpython-314.pyc deleted file mode 100644 index b7e2ac5fbc4b7992d3551829669fd4b6b380126b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6453 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