diff --git a/.github/scripts/select_builds.py b/.github/scripts/select_builds.py new file mode 100644 index 00000000..67fb57b9 --- /dev/null +++ b/.github/scripts/select_builds.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 + +"""Select firmware products and host tests from a Git change set.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Iterable, Sequence + + +ZERO_SHA = "0" * 40 + +DOCUMENTATION_CONFIG_PATHS = { + ".markdownlint-cli2.jsonc", + ".markdownlint.json", +} + +NON_BUILD_PATHS = { + ".clang-format", + ".editorconfig", + ".gitignore", + ".github/workflows/pre-commit.yml", + ".pre-commit-config.yaml", + "scripts/merge_compile_commands.py", +} + +HOST_TEST_ONLY_PATHS = { + ".github/scripts/test_select_builds.py", + ".github/workflows/host-tests.yml", +} + +ALL_BUILD_PATHS = { + ".github/scripts/select_builds.py", + ".github/workflows/firmware-build.yml", + ".gitmodules", +} + + +@dataclass(frozen=True) +class BuildSelection: + products: tuple[str, ...] + host_tests_required: bool + + @property + def firmware_required(self) -> bool: + return bool(self.products) + + +def discover_products(repository_root: Path) -> tuple[str, ...]: + products_root = repository_root / "products" + products = ( + path.name + for path in products_root.iterdir() + if path.is_dir() and (path / "CMakeLists.txt").is_file() + ) + return tuple(sorted(products)) + + +def is_documentation(path: PurePosixPath) -> bool: + path_string = path.as_posix() + if path_string in DOCUMENTATION_CONFIG_PATHS: + return True + if path.name == "LICENSE" or path.name.startswith("LICENSE."): + return True + + parts = path.parts + if not parts: + return False + if parts[0] == "docs": + return True + if len(parts) == 1: + return path.suffix.lower() == ".md" + if parts[0] == ".github": + return path.suffix.lower() == ".md" + if parts[0] == "tests": + return path.suffix.lower() == ".md" + + if parts[0] == "components": + if len(parts) <= 3: + return path.suffix.lower() == ".md" + return path.name in {"CHANGELOG.md", "README.md"} + + if parts[0] == "products": + if len(parts) <= 3: + return path.suffix.lower() == ".md" + if parts[2] in {"docs", "specs"}: + return True + return "tests" in parts[2:] and path.name == "README.md" + + return False + + +def is_test_path(path: PurePosixPath) -> bool: + if path.parts and path.parts[0] == "tests": + return True + if len(path.parts) < 3 or path.parts[0] not in {"components", "products"}: + return False + return "tests" in path.parts[2:] + + +def classify_paths( + changed_paths: Iterable[str], all_products: Sequence[str] +) -> BuildSelection: + known_products = set(all_products) + selected_products: set[str] = set() + host_tests_required = False + + for path_string in changed_paths: + path = PurePosixPath(path_string) + normalized_path = path.as_posix() + + if is_documentation(path) or normalized_path in NON_BUILD_PATHS: + continue + + if is_test_path(path) or normalized_path in HOST_TEST_ONLY_PATHS: + host_tests_required = True + continue + + if normalized_path in ALL_BUILD_PATHS: + selected_products.update(known_products) + host_tests_required = True + continue + + if path.parts and path.parts[0] == "components": + selected_products.update(known_products) + host_tests_required = True + continue + + if len(path.parts) >= 2 and path.parts[0] == "products": + product = path.parts[1] + if product in known_products: + selected_products.add(product) + else: + selected_products.update(known_products) + host_tests_required = True + continue + + # Unknown files fail safe by selecting every known product. + selected_products.update(known_products) + host_tests_required = True + + return BuildSelection( + products=tuple(sorted(selected_products)), + host_tests_required=host_tests_required, + ) + + +def commit_exists(repository_root: Path, revision: str) -> bool: + result = subprocess.run( + ["git", "cat-file", "-e", f"{revision}^{{commit}}"], + cwd=repository_root, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def read_changed_paths( + repository_root: Path, + base: str, + head: str, + use_merge_base: bool = False, +) -> list[str]: + if base == ZERO_SHA or not commit_exists(repository_root, base): + raise ValueError(f"base commit is unavailable: {base}") + if not commit_exists(repository_root, head): + raise ValueError(f"head commit is unavailable: {head}") + + comparison = f"{base}...{head}" if use_merge_base else f"{base}..{head}" + result = subprocess.run( + [ + "git", + "diff", + "--name-only", + "--no-renames", + "-z", + comparison, + ], + cwd=repository_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + error = result.stderr.decode(errors="replace").strip() + raise RuntimeError(f"git diff failed: {error}") + + return [ + os.fsdecode(path) + for path in result.stdout.split(b"\0") + if path + ] + + +def write_github_outputs(output_path: Path, selection: BuildSelection) -> None: + products = json.dumps(selection.products, separators=(",", ":")) + with output_path.open("a", encoding="utf-8") as output: + output.write(f"products={products}\n") + output.write( + f"firmware_required={str(selection.firmware_required).lower()}\n" + ) + output.write( + f"host_tests_required={str(selection.host_tests_required).lower()}\n" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True, help="Base Git revision") + parser.add_argument("--head", required=True, help="Head Git revision") + parser.add_argument( + "--merge-base", + action="store_true", + help="Compare the merge base with the head revision", + ) + parser.add_argument( + "--github-output", + type=Path, + default=None, + help="GitHub Actions output file", + ) + parser.add_argument( + "--repository-root", + type=Path, + default=Path.cwd(), + help="Repository root", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repository_root = args.repository_root.resolve() + products = discover_products(repository_root) + if not products: + print("No firmware products were discovered", file=sys.stderr) + return 1 + + try: + changed_paths = read_changed_paths( + repository_root, + args.base, + args.head, + use_merge_base=args.merge_base, + ) + selection = classify_paths(changed_paths, products) + except (RuntimeError, ValueError) as error: + print(f"{error}; selecting all builds", file=sys.stderr) + changed_paths = [] + selection = BuildSelection(products=products, host_tests_required=True) + + print("Changed paths:") + for path in changed_paths: + print(f" {path}") + print(f"Firmware products: {', '.join(selection.products) or 'none'}") + print(f"Host tests required: {selection.host_tests_required}") + + if args.github_output is not None: + write_github_outputs(args.github_output, selection) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_select_builds.py b/.github/scripts/test_select_builds.py new file mode 100644 index 00000000..e1100672 --- /dev/null +++ b/.github/scripts/test_select_builds.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 + +"""Tests for the CI build selector.""" + +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from pathlib import Path + +from select_builds import BuildSelection, classify_paths, read_changed_paths + + +PRODUCTS = ("go", "reference") + + +class ClassifyPathsTest(unittest.TestCase): + def assert_selection( + self, + paths: list[str], + products: tuple[str, ...], + host_tests_required: bool, + ) -> None: + self.assertEqual( + classify_paths(paths, PRODUCTS), + BuildSelection( + products=products, + host_tests_required=host_tests_required, + ), + ) + + def test_documentation_only_selects_no_builds(self) -> None: + self.assert_selection( + ["README.md", "docs/STYLE.md", "products/go/docs/ble.md"], + (), + False, + ) + + def test_product_source_selects_only_that_product(self) -> None: + self.assert_selection( + ["products/go/main/main.cpp"], + ("go",), + True, + ) + + def test_embedded_product_asset_selects_product(self) -> None: + self.assert_selection( + ["products/reference/main/web/index.html"], + ("reference",), + True, + ) + + def test_embedded_markdown_selects_product(self) -> None: + self.assert_selection( + ["products/go/main/help.md"], + ("go",), + True, + ) + + def test_component_source_under_docs_directory_selects_all(self) -> None: + self.assert_selection( + ["components/airgradient-common/docs/generated.cpp"], + PRODUCTS, + True, + ) + + def test_markdown_under_unknown_path_fails_safe(self) -> None: + self.assert_selection( + ["new-build-system/design.md"], + PRODUCTS, + True, + ) + + def test_shared_component_selects_every_product(self) -> None: + self.assert_selection( + ["components/airgradient-common/include/common.h"], + PRODUCTS, + True, + ) + + def test_test_only_change_selects_host_tests(self) -> None: + self.assert_selection( + ["components/airgradient-ota/tests/test_ota.cpp"], + (), + True, + ) + + def test_unknown_path_fails_safe(self) -> None: + self.assert_selection( + ["new-build-system/config.yaml"], + PRODUCTS, + True, + ) + + def test_unknown_product_fails_safe(self) -> None: + self.assert_selection( + ["products/unknown/main/main.cpp"], + PRODUCTS, + True, + ) + + def test_new_discovered_product_is_selected(self) -> None: + selection = classify_paths( + ["products/outdoor/main/main.cpp"], + ("go", "outdoor", "reference"), + ) + self.assertEqual( + selection, + BuildSelection(products=("outdoor",), host_tests_required=True), + ) + + def test_host_workflow_change_does_not_select_firmware(self) -> None: + self.assert_selection( + [".github/workflows/host-tests.yml"], + (), + True, + ) + + def test_selector_change_selects_all_validation(self) -> None: + self.assert_selection( + [".github/scripts/select_builds.py"], + PRODUCTS, + True, + ) + + def test_selector_test_change_selects_only_host_tests(self) -> None: + self.assert_selection( + [".github/scripts/test_select_builds.py"], + (), + True, + ) + + +class ReadChangedPathsTest(unittest.TestCase): + def run_git(self, repository: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repository, + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout.strip() + + def test_merge_base_excludes_changes_made_only_on_base_branch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + self.run_git(repository, "init", "--quiet") + self.run_git(repository, "config", "user.email", "ci@example.com") + self.run_git(repository, "config", "user.name", "CI Test") + + product_file = repository / "products/go/main/main.cpp" + component_file = repository / "components/common/common.cpp" + product_file.parent.mkdir(parents=True) + component_file.parent.mkdir(parents=True) + product_file.write_text("root\n", encoding="utf-8") + component_file.write_text("root\n", encoding="utf-8") + self.run_git(repository, "add", ".") + self.run_git(repository, "commit", "--quiet", "-m", "root") + root_branch = self.run_git(repository, "branch", "--show-current") + + self.run_git(repository, "checkout", "--quiet", "-b", "feature") + product_file.write_text("feature\n", encoding="utf-8") + self.run_git(repository, "commit", "--quiet", "-am", "feature") + feature_head = self.run_git(repository, "rev-parse", "HEAD") + + self.run_git(repository, "checkout", "--quiet", root_branch) + component_file.write_text("base\n", encoding="utf-8") + self.run_git(repository, "commit", "--quiet", "-am", "base") + base_head = self.run_git(repository, "rev-parse", "HEAD") + + self.assertEqual( + read_changed_paths( + repository, + base_head, + feature_head, + use_merge_base=True, + ), + ["products/go/main/main.cpp"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml new file mode 100644 index 00000000..7a8bd643 --- /dev/null +++ b/.github/workflows/firmware-build.yml @@ -0,0 +1,131 @@ +name: firmware-build + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: firmware-build-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + detect-changes: + name: detect firmware changes + runs-on: ubuntu-latest + outputs: + firmware-required: ${{ steps.select.outputs.firmware_required }} + products: ${{ steps.select.outputs.products }} + steps: + - name: Check out + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Test build selector + run: python3 -m unittest discover -s .github/scripts -p "test_*.py" + + - name: Select builds + id: select + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + PUSH_HEAD_SHA: ${{ github.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + base_sha="$PR_BASE_SHA" + head_sha="$PR_HEAD_SHA" + comparison_args=(--merge-base) + else + base_sha="$PUSH_BASE_SHA" + head_sha="$PUSH_HEAD_SHA" + comparison_args=() + fi + + python3 .github/scripts/select_builds.py \ + --base "$base_sha" \ + --head "$head_sha" \ + "${comparison_args[@]}" \ + --github-output "$GITHUB_OUTPUT" + + firmware-build: + name: firmware (${{ matrix.product }}) + if: needs.detect-changes.outputs.firmware-required == 'true' + needs: detect-changes + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + product: ${{ fromJSON(needs.detect-changes.outputs.products) }} + steps: + - name: Check out + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + submodules: recursive + + - name: Require dependency lockfile + run: git ls-files --error-unmatch "products/${{ matrix.product }}/dependencies.lock" > /dev/null + + - name: Restore ESP-IDF managed components + uses: actions/cache@v4.2.3 + with: + path: products/${{ matrix.product }}/managed_components + key: idf-managed-${{ runner.os }}-v5.5.4-esp32c5-${{ matrix.product }}-${{ hashFiles('products/**/dependencies.lock', 'products/**/idf_component.yml', 'components/**/idf_component.yml') }} + + - name: Build firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.5.4 + target: esp32c5 + path: products/${{ matrix.product }} + command: idf.py build + + - name: Verify dependency lockfile + run: | + lockfile="products/${{ matrix.product }}/dependencies.lock" + if [ -n "$(git status --porcelain --untracked-files=all -- "$lockfile")" ]; then + git status --short --untracked-files=all -- "$lockfile" + exit 1 + fi + + firmware-build-result: + name: firmware build result + if: always() + needs: [detect-changes, firmware-build] + runs-on: ubuntu-latest + steps: + - name: Check firmware build result + env: + DETECT_RESULT: ${{ needs.detect-changes.result }} + FIRMWARE_REQUIRED: ${{ needs.detect-changes.outputs.firmware-required }} + FIRMWARE_RESULT: ${{ needs.firmware-build.result }} + run: | + if [ "$DETECT_RESULT" != "success" ]; then + echo "Change detection failed" + exit 1 + fi + + if [ "$FIRMWARE_REQUIRED" != "true" ] && \ + [ "$FIRMWARE_REQUIRED" != "false" ]; then + echo "Invalid firmware-required output: $FIRMWARE_REQUIRED" + exit 1 + fi + + if [ "$FIRMWARE_REQUIRED" = "true" ] && \ + [ "$FIRMWARE_RESULT" != "success" ]; then + echo "Required firmware build did not succeed: $FIRMWARE_RESULT" + exit 1 + fi + + if [ "$FIRMWARE_REQUIRED" = "true" ]; then + echo "Required firmware builds succeeded" + else + echo "No firmware build was required" + fi diff --git a/.github/workflows/host-tests.yml b/.github/workflows/host-tests.yml index ecc38bc6..dec94b5c 100644 --- a/.github/workflows/host-tests.yml +++ b/.github/workflows/host-tests.yml @@ -5,9 +5,57 @@ on: push: branches: [main] +permissions: + contents: read + +concurrency: + group: host-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + detect-changes: + name: detect host-test changes + runs-on: ubuntu-latest + outputs: + host-tests-required: ${{ steps.select.outputs.host_tests_required }} + steps: + - name: Check out + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Test build selector + run: python3 -m unittest discover -s .github/scripts -p "test_*.py" + + - name: Select tests + id: select + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + PUSH_HEAD_SHA: ${{ github.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + base_sha="$PR_BASE_SHA" + head_sha="$PR_HEAD_SHA" + comparison_args=(--merge-base) + else + base_sha="$PUSH_BASE_SHA" + head_sha="$PUSH_HEAD_SHA" + comparison_args=() + fi + + python3 .github/scripts/select_builds.py \ + --base "$base_sha" \ + --head "$head_sha" \ + "${comparison_args[@]}" \ + --github-output "$GITHUB_OUTPUT" + host-tests: - name: host tests + name: native host tests + if: needs.detect-changes.outputs.host-tests-required == 'true' + needs: detect-changes runs-on: ubuntu-latest steps: - name: Check out @@ -16,14 +64,31 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Require dependency lockfile + run: git ls-files --error-unmatch products/go/dependencies.lock > /dev/null + + - name: Restore ESP-IDF managed components + uses: actions/cache@v4.2.3 + with: + path: products/go/managed_components + key: idf-managed-${{ runner.os }}-v5.5.4-esp32c5-go-${{ hashFiles('products/**/dependencies.lock', 'products/**/idf_component.yml', 'components/**/idf_component.yml') }} + - name: Populate ESP-IDF managed components uses: espressif/esp-idf-ci-action@v1 with: - esp_idf_version: v5.5.2 + esp_idf_version: v5.5.4 target: esp32c5 path: products/go command: idf.py reconfigure + - name: Verify dependency lockfile + run: | + lockfile="products/go/dependencies.lock" + if [ -n "$(git status --porcelain --untracked-files=all -- "$lockfile")" ]; then + git status --short --untracked-files=all -- "$lockfile" + exit 1 + fi + - name: Show tool versions run: | cmake --version @@ -37,3 +102,38 @@ jobs: - name: Run host tests run: ctest --test-dir tests/build --output-on-failure + + host-tests-result: + name: host tests + if: always() + needs: [detect-changes, host-tests] + runs-on: ubuntu-latest + steps: + - name: Check host-test result + env: + DETECT_RESULT: ${{ needs.detect-changes.result }} + HOST_TESTS_REQUIRED: ${{ needs.detect-changes.outputs.host-tests-required }} + HOST_TESTS_RESULT: ${{ needs.host-tests.result }} + run: | + if [ "$DETECT_RESULT" != "success" ]; then + echo "Change detection failed" + exit 1 + fi + + if [ "$HOST_TESTS_REQUIRED" != "true" ] && \ + [ "$HOST_TESTS_REQUIRED" != "false" ]; then + echo "Invalid host-tests-required output: $HOST_TESTS_REQUIRED" + exit 1 + fi + + if [ "$HOST_TESTS_REQUIRED" = "true" ] && \ + [ "$HOST_TESTS_RESULT" != "success" ]; then + echo "Required host tests did not succeed: $HOST_TESTS_RESULT" + exit 1 + fi + + if [ "$HOST_TESTS_REQUIRED" = "true" ]; then + echo "Required host tests succeeded" + else + echo "No host tests were required" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b377dc4a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +name: release + +# Monorepo release entrypoint. Product tag prefixes select product-specific +# release jobs; currently only AirGradient Go is published. + +on: + push: + tags: + - "go-v*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-go: + name: Build AirGradient Go release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.value }} + steps: + - name: Check out + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + submodules: recursive + + - name: Validate release tag + id: version + env: + MAX_RELEASE_VERSION_LENGTH: 16 + TAG_NAME: ${{ github.ref_name }} + run: | + if [[ ! "$TAG_NAME" =~ ^go-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid AirGradient Go release tag: $TAG_NAME" + exit 1 + fi + + version="${TAG_NAME#go-v}" + if [ "${#version}" -gt "$MAX_RELEASE_VERSION_LENGTH" ]; then + echo "AirGradient Go release version exceeds $MAX_RELEASE_VERSION_LENGTH characters: $version" + exit 1 + fi + + echo "value=$version" >> "$GITHUB_OUTPUT" + + - name: Require tag from main + env: + TAG_NAME: ${{ github.ref_name }} + run: | + tag_commit=$(git rev-parse "${TAG_NAME}^{commit}") + if ! git merge-base --is-ancestor "$tag_commit" origin/main; then + echo "Release tag must point to a commit on main" + exit 1 + fi + + - name: Require dependency lockfile + run: git ls-files --error-unmatch products/go/dependencies.lock > /dev/null + + - name: Restore ESP-IDF managed components + uses: actions/cache@v4.2.3 + with: + path: products/go/managed_components + key: idf-managed-${{ runner.os }}-v5.5.4-esp32c5-go-${{ hashFiles('products/**/dependencies.lock', 'products/**/idf_component.yml', 'components/**/idf_component.yml') }} + + - name: Build firmware + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: v5.5.4 + target: esp32c5 + path: products/go + command: idf.py merge-bin -o airgradient-go-merge.bin -f raw + + - name: Verify release build + env: + EXPECTED_VERSION: ${{ steps.version.outputs.value }} + run: | + lockfile="products/go/dependencies.lock" + if [ -n "$(git status --porcelain --untracked-files=all -- "$lockfile")" ]; then + git status --short --untracked-files=all -- "$lockfile" + exit 1 + fi + + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "Release build modified tracked files" + git status --short --untracked-files=no + exit 1 + fi + + actual_version=$(python3 -c 'import json; print(json.load(open("products/go/build/project_description.json"))["project_version"])') + if [ "$actual_version" != "$EXPECTED_VERSION" ]; then + echo "Expected firmware version $EXPECTED_VERSION, got $actual_version" + exit 1 + fi + + - name: Upload firmware artifacts + uses: actions/upload-artifact@v4 + with: + name: firmware-go-${{ github.run_id }} + if-no-files-found: error + overwrite: true + path: | + products/go/build/airgradient-go.bin + products/go/build/ota_data_initial.bin + products/go/build/bootloader/bootloader.bin + products/go/build/partition_table/partition-table.bin + products/go/build/airgradient-go-merge.bin + + publish-go: + name: Publish AirGradient Go release + needs: build-go + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download firmware artifacts + uses: actions/download-artifact@v4 + with: + name: firmware-go-${{ github.run_id }} + path: output + + - name: Create release ZIP + env: + VERSION: ${{ needs.build-go.outputs.version }} + run: | + cd output + zip -r "../airgradient-go-firmware-${VERSION}.zip" . + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_VERSION: ${{ needs.build-go.outputs.version }} + run: | + release_asset="airgradient-go-firmware-${RELEASE_VERSION}.zip" + if gh release view "$RELEASE_TAG" > /dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "$release_asset" --clobber + else + gh release create "$RELEASE_TAG" "$release_asset" \ + --verify-tag \ + --title "AirGradient Go v${RELEASE_VERSION}" \ + --generate-notes + fi diff --git a/README.md b/README.md index 62df9cba..2730c9aa 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,19 @@ idf.py -C products/go build idf.py -C products/reference build ``` +## Release Firmware + +Firmware releases use product-prefixed Git tags and the repository-level +[`release.yml`](.github/workflows/release.yml) workflow. Each releasable product +owns a tag prefix and a product-specific release job within that workflow. + +| Tag Pattern | Product | Published Output | +|---|---|---| +| `go-vMAJOR.MINOR.PATCH` | AirGradient Go | Versioned firmware bundle ZIP attached to a GitHub Release | + +Reference is a smoke-test product and has no release tag. Future shipping +products extend the same workflow with their own tag trigger and release job. + ## Run Host Tests ```sh @@ -51,6 +64,30 @@ cmake --build tests/build ctest --test-dir tests/build --output-on-failure ``` +## Continuous Integration + +Pull requests and pushes to `main` use the shared build selector in +[`select_builds.py`](.github/scripts/select_builds.py). Firmware products are +discovered from product directories containing a `CMakeLists.txt` file. + +| Changed Files | Firmware Builds | Host Tests | +|---|---|---| +| Documentation and known non-build tooling only | Skipped | Skipped | +| One product's production files | Changed product | Run | +| Shared component production files | All products | Run | +| Host-test files only | Skipped | Run | +| Unknown or unclassified files | All products | Run | + +[`firmware-build.yml`](.github/workflows/firmware-build.yml) builds the selected +products with ESP-IDF v5.5.4. The workflow caches managed components using the +dependency lockfiles and manifests, but does not cache build directories or +upload firmware binaries. [`host-tests.yml`](.github/workflows/host-tests.yml) +uses the same ESP-IDF version and managed-component cache. + +Both workflows retain an always-reporting result job when compilation is +intentionally skipped. Dependency resolution must not modify the committed +`dependencies.lock` file. + ## Editor Compile Database For clangd or Neovim LSP, this repo can generate compile databases in multiple @@ -115,10 +152,9 @@ The same pre-commit hooks run on every pull request via [`pre-commit.yml`](.github/workflows/pre-commit.yml), including `clang-format` and Markdown lint. PRs that fail formatting or lint checks are blocked. -GitHub Actions also initializes submodules, populates ESP-IDF managed -components, then configures, builds, and runs the native host-test suite on -every pull request and push to `main` via -[`host-tests.yml`](.github/workflows/host-tests.yml). +GitHub Actions applies the change-aware firmware and host-test policy described +in [Continuous Integration](#continuous-integration) on every pull request and +push to `main`. To run the hooks on the currently staged files before committing: diff --git a/components/airgradient-local-server/types/system_info.h b/components/airgradient-local-server/types/system_info.h index a5310a76..fa96f919 100644 --- a/components/airgradient-local-server/types/system_info.h +++ b/components/airgradient-local-server/types/system_info.h @@ -20,7 +20,7 @@ struct SystemInfo { char serial_number[24] = {}; // "serialNumber" char model[32] = {}; // "model" - char firmware[16] = {}; // "firmware" + char firmware[32] = {}; // "firmware" std::optional wifi_rssi; // "wifiRssi" (dBm; omitted when unavailable) uint32_t boot = 0; // "boot": measurement-cycle counter; resets on restart }; diff --git a/products/go/CMakeLists.txt b/products/go/CMakeLists.txt index 463ca5fa..b0d9781a 100644 --- a/products/go/CMakeLists.txt +++ b/products/go/CMakeLists.txt @@ -25,5 +25,122 @@ set(COMPONENTS ads1115 ) +# Release tags use go-vMAJOR.MINOR.PATCH. Development builds retain the latest +# reachable release version and add the current commit and dirty state. +set(AG_GO_VERSION_MAX_LENGTH 31) +set(AG_GO_RELEASE_VERSION_MAX_LENGTH 16) +set(AG_GO_RELEASE_TAG_PATTERN + "^go-v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$" +) +set(AG_GO_REPOSITORY_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..") +find_package(Git QUIET) + +if(GIT_FOUND) + execute_process( + COMMAND "${GIT_EXECUTABLE}" rev-parse HEAD + WORKING_DIRECTORY "${AG_GO_REPOSITORY_ROOT}" + RESULT_VARIABLE git_head_result + OUTPUT_VARIABLE git_head + ERROR_VARIABLE git_head_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ) + if(NOT git_head_result EQUAL 0) + message(FATAL_ERROR "Unable to determine Go commit: ${git_head_error}") + endif() + string(SUBSTRING "${git_head}" 0 7 commit_hash) + + execute_process( + COMMAND "${GIT_EXECUTABLE}" tag --merged HEAD --list "go-v*" + WORKING_DIRECTORY "${AG_GO_REPOSITORY_ROOT}" + RESULT_VARIABLE git_tags_result + OUTPUT_VARIABLE git_tags_output + ERROR_VARIABLE git_tags_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ) + if(NOT git_tags_result EQUAL 0) + message(FATAL_ERROR "Unable to inspect Go release tags: ${git_tags_error}") + endif() + + string(REPLACE "\n" ";" reachable_tags "${git_tags_output}") + set(valid_tags "") + foreach(tag IN LISTS reachable_tags) + if(tag MATCHES "${AG_GO_RELEASE_TAG_PATTERN}") + set(tag_version "${CMAKE_MATCH_1}") + string(LENGTH "${tag_version}" tag_version_length) + if(tag_version_length LESS_EQUAL AG_GO_RELEASE_VERSION_MAX_LENGTH) + list(APPEND valid_tags "${tag}") + endif() + endif() + endforeach() + + if(valid_tags) + set(git_describe_command + "${GIT_EXECUTABLE}" describe --tags --long --abbrev=7 + ) + foreach(tag IN LISTS valid_tags) + list(APPEND git_describe_command --match "${tag}") + endforeach() + execute_process( + COMMAND ${git_describe_command} + WORKING_DIRECTORY "${AG_GO_REPOSITORY_ROOT}" + RESULT_VARIABLE git_describe_result + OUTPUT_VARIABLE git_description + ERROR_VARIABLE git_describe_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ) + if(NOT git_describe_result EQUAL 0) + message(FATAL_ERROR + "Unable to determine Go firmware version: ${git_describe_error}") + endif() + if(NOT git_description MATCHES + "^go-v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))-([0-9]+)-g([0-9a-f]+)$") + message(FATAL_ERROR "Unexpected Go git description: ${git_description}") + endif() + set(base_version "${CMAKE_MATCH_1}") + set(commit_count "${CMAKE_MATCH_5}") + string(SUBSTRING "${CMAKE_MATCH_6}" 0 7 commit_hash) + else() + set(base_version "0.0.0") + set(commit_count "-1") + endif() + + execute_process( + COMMAND "${GIT_EXECUTABLE}" status --porcelain --untracked-files=normal + WORKING_DIRECTORY "${AG_GO_REPOSITORY_ROOT}" + RESULT_VARIABLE git_status_result + OUTPUT_VARIABLE git_status + ERROR_VARIABLE git_status_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE + ) + if(NOT git_status_result EQUAL 0) + message(FATAL_ERROR "Unable to inspect Go worktree: ${git_status_error}") + endif() + + if(git_status) + set(dirty_suffix "-dirty") + else() + set(dirty_suffix "") + endif() + + if(commit_count STREQUAL "0" AND NOT dirty_suffix) + set(PROJECT_VER "${base_version}") + else() + set(PROJECT_VER "${base_version}-g${commit_hash}${dirty_suffix}") + endif() +else() + set(PROJECT_VER "0.0.0-unknown") +endif() + +string(LENGTH "${PROJECT_VER}" project_ver_length) +if(project_ver_length GREATER AG_GO_VERSION_MAX_LENGTH) + message(FATAL_ERROR + "Go firmware version exceeds ${AG_GO_VERSION_MAX_LENGTH} characters: ${PROJECT_VER}") +endif() +message(STATUS "Resolved Go firmware version: ${PROJECT_VER}") + include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(airgradient-go) diff --git a/products/go/README.md b/products/go/README.md index f1bd917f..b4a4cbe6 100644 --- a/products/go/README.md +++ b/products/go/README.md @@ -125,6 +125,34 @@ BQ25629 ADC telemetry, and FG telemetry with decoded flags idf.py -C products/go build ``` +The build derives `PROJECT_VER` from the latest reachable +`go-vMAJOR.MINOR.PATCH` tag: + +- exact clean `go-v1.2.3` tag — `1.2.3` +- later clean commit — `1.2.3-gabcdef0` +- tracked or untracked worktree changes — `1.2.3-gabcdef0-dirty` +- no matching tag — `0.0.0-gabcdef0`, with `-dirty` when applicable + +The `MAJOR.MINOR.PATCH` portion is limited to 16 characters so the complete +development version remains within ESP-IDF's 31-character application-version +field. + +Version resolution happens during CMake configuration. When reusing an existing +build directory, run `idf.py -C products/go reconfigure` after changing tags or +worktree cleanliness. + +Push an annotated tag whose commit is already on `main` to build and publish +the firmware bundle through the repository-level +[`release.yml`](../../.github/workflows/release.yml) workflow: + +```sh +git tag -a go-v1.2.3 -m "AirGradient Go v1.2.3" +git push origin go-v1.2.3 +``` + +The release ZIP contains the OTA application, OTA data initializer, bootloader, +partition table, and merged factory-flash binary. + ## Documentation - [`ARCHITECTURE.md`](ARCHITECTURE.md) — boot paths, event model, module diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index fb80f330..c186fd88 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -29,7 +29,7 @@ class GoLocalApiServiceTestAccess { namespace { constexpr const char *TEST_SERIAL = "aabbccddeeff"; -constexpr const char *TEST_FIRMWARE = "1.2.3"; +constexpr const char *TEST_FIRMWARE = "1.2.3-gabcdef0-dirty"; class TestRtos final : public RTOS { public: diff --git a/products/go/version.txt b/products/go/version.txt deleted file mode 100644 index 7dea76ed..00000000 --- a/products/go/version.txt +++ /dev/null @@ -1 +0,0 @@ -1.0.1