From bcbe0edcfd6b9bb67908d4318651888850ad4602 Mon Sep 17 00:00:00 2001 From: Ryan K Date: Mon, 22 Apr 2024 17:23:42 -0700 Subject: [PATCH] ci: Add workflows for build, test, and release actions (#699) Co-authored-by: Ryan Kelly --- .github/workflows/azdev_linter.yml | 55 +++++++++++++ .github/workflows/ci_build.yml | 23 ++++++ .github/workflows/ci_workflow.yml | 19 +++++ .github/workflows/codeql.yml | 29 +++++++ .github/workflows/conventional_pr.yml | 24 ++++++ .github/workflows/release_build.yml | 44 ++++++++++ .github/workflows/release_workflow.yml | 58 ++++++++++++++ .github/workflows/security_checks.yml | 49 ++++++++++++ .github/workflows/stage_release.yml | 44 ++++++++++ .github/workflows/tox.yml | 64 +++++++++++++++ dev_requirements | 2 +- tox.ini | 106 ++++++++++++++++++++++--- 12 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/azdev_linter.yml create mode 100644 .github/workflows/ci_build.yml create mode 100644 .github/workflows/ci_workflow.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/conventional_pr.yml create mode 100644 .github/workflows/release_build.yml create mode 100644 .github/workflows/release_workflow.yml create mode 100644 .github/workflows/security_checks.yml create mode 100644 .github/workflows/stage_release.yml create mode 100644 .github/workflows/tox.yml diff --git a/.github/workflows/azdev_linter.yml b/.github/workflows/azdev_linter.yml new file mode 100644 index 000000000..785f8628c --- /dev/null +++ b/.github/workflows/azdev_linter.yml @@ -0,0 +1,55 @@ +name: "[auto] CLI Command Table Linter" +on: + workflow_call: + inputs: + continue-on-error: + type: boolean + required: false + default: false + +jobs: + linter: + permissions: + contents: read + continue-on-error: ${{ inputs.continue-on-error }} + name: Evaluate command table + runs-on: ubuntu-latest + steps: + # checkout source (for linter_exclusions) + - uses: actions/checkout@v4 + + # download built wheel (from ./release_build.yml) + - name: Download Wheel + uses: actions/download-artifact@v4 + with: + name: azure-iot-cli-ext + path: ./extension + + # Install python + - uses: actions/setup-python@v5 + name: Setup python + with: + python-version: "3.11" + + # Lint + - name: azdev linter + run: | + set -ev + pip install virtualenv + python -m virtualenv venv/ + source ./venv/bin/activate + git clone --single-branch -b dev https://github.com/Azure/azure-cli.git ../azure-cli + pip install azdev + azdev --version + azdev setup -c ../azure-cli -r ./ + AZURE_EXTENSION_DIR=~/.azure/cliextensions + ARTIFACTS_DIR=./extension + WHEELS=$(ls $ARTIFACTS_DIR/*.whl) + az --version + for i in $WHEELS; do + az extension add --source $i -y --debug + done + cp ./linter_exclusions.yml $AZURE_EXTENSION_DIR/azure-iot/ + # temp fix for newest azdev v0.1.65 + cp .pylintrc pylintrc + azdev linter --include-whl-extensions azure-iot --min-severity medium \ No newline at end of file diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml new file mode 100644 index 000000000..2a75282f7 --- /dev/null +++ b/.github/workflows/ci_build.yml @@ -0,0 +1,23 @@ +name: "[auto] Simple Build" +on: + workflow_call: +jobs: + build: + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Build Wheel + run: | + pip install wheel + python -m setup bdist_wheel -d dist + - name: Upload Wheel Artifact + uses: actions/upload-artifact@v4 + with: + path: dist/*.whl + name: azure-iot-cli-ext \ No newline at end of file diff --git a/.github/workflows/ci_workflow.yml b/.github/workflows/ci_workflow.yml new file mode 100644 index 000000000..61394fb0f --- /dev/null +++ b/.github/workflows/ci_workflow.yml @@ -0,0 +1,19 @@ +name: CI Build and Test +permissions: + contents: read +on: + pull_request: + branches: + - dev + push: + branches: + - dev + workflow_dispatch: +jobs: + build: + uses: ./.github/workflows/ci_build.yml + test: + uses: ./.github/workflows/tox.yml + linter: + needs: [build] + uses: ./.github/workflows/azdev_linter.yml \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..6438fcf5e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,29 @@ +name: "[auto] CodeQL-Nightly" + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +jobs: + CodeQL-Build: + # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest + runs-on: ubuntu-latest + + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: python + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/conventional_pr.yml b/.github/workflows/conventional_pr.yml new file mode 100644 index 000000000..f2707b116 --- /dev/null +++ b/.github/workflows/conventional_pr.yml @@ -0,0 +1,24 @@ +name: "[auto] Check PR title format" +on: + pull_request: + types: [ + opened, + edited, + ready_for_review, + reopened, + ] +jobs: + verify_title: + runs-on: ubuntu-latest + steps: + - name: "Verify PR title matches conventional commits specification" + env: + TITLE: ${{ github.event.pull_request.title }} + run: | + conventional_regex='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.*\))?: .*$' + if [[ "$TITLE" =~ $conventional_regex ]]; then + echo "Success!" + else + echo "Incorrect PR title format" >> $GITHUB_STEP_SUMMARY + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml new file mode 100644 index 000000000..5412c1bc2 --- /dev/null +++ b/.github/workflows/release_build.yml @@ -0,0 +1,44 @@ +name: "[auto] Build Wheel for Release" +on: + workflow_call: + +jobs: + build: + runs-on: [self-hosted, 1ES.Pool=iotupx-iot-cli-github-hosted-pool, 1ES.ImageOverride=Ubuntu20.04Compliant] + permissions: + contents: read + steps: + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - uses: actions/checkout@v4 + + - name: Build Wheel + run: | + pip install wheel==0.30.0 + python -m setup bdist_wheel -d dist + - name: Determine Wheel Version + run: | + wheel=$(find ./dist/*.whl) + echo "wheel=$wheel" >> $GITHUB_ENV + pip install $wheel + version=$(pip show azure_iot | grep Version: | awk '{print $2}') + echo "version=$version" >> $GITHUB_ENV + - name: Generate SBOM + run: | + curl -Lo $RUNNER_TEMP/sbom-tool https://github.com/microsoft/sbom-tool/releases/download/v2.2.3/sbom-tool-linux-x64 + chmod +x $RUNNER_TEMP/sbom-tool + $RUNNER_TEMP/sbom-tool generate -b ./dist -bc . -pn "Azure IoT CLI Extension" -pv "${{ env.version }}" -ps Microsoft + + - name: Upload Wheel Artifact + uses: actions/upload-artifact@v4 + with: + path: ${{ env.wheel }} + name: azure-iot-cli-ext + - name: Upload SBOM Artifact + uses: actions/upload-artifact@v4 + with: + path: dist/_manifest/ + name: SBOM \ No newline at end of file diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml new file mode 100644 index 000000000..f65ce1784 --- /dev/null +++ b/.github/workflows/release_workflow.yml @@ -0,0 +1,58 @@ +name: Build and Publish Release +run-name: Build and publish release${{ github.event.inputs.github_release == 'true' && ' - Stage Release' || ''}} +on: + # only manual trigger + workflow_dispatch: + inputs: + continue-on-error: + description: Continue release if pre-checks fail + type: boolean + required: false + default: false + github_release: + description: Stage github release + type: boolean + required: false + default: false +jobs: + security: + permissions: + # needed to write security info to repository + security-events: write + contents: read + uses: ./.github/workflows/security_checks.yml + with: + continue-on-error: ${{ github.event.inputs.continue-on-error == 'true' }} + build: + uses: ./.github/workflows/release_build.yml + unit-test: + uses: ./.github/workflows/tox.yml + with: + continue-on-error: ${{ github.event.inputs.continue-on-error == 'true' }} + azdev_linter: + needs: [build] + uses: ./.github/workflows/azdev_linter.yml + with: + continue-on-error: ${{ github.event.inputs.continue-on-error == 'true' }} + approval: + needs: [security, build, unit-test, azdev_linter] + # only needed if (release || wheel) - conditionals allow previous jobs to be skipped and still run + if: always() && !cancelled() && !failure() && (github.event.inputs.github_release == 'true') + environment: production + runs-on: ubuntu-latest + steps: + - name: Confirm + run: | + echo "Approved" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.github_release }}" == "true" ]; then + echo "Github release will be drafted." >> $GITHUB_STEP_SUMMARY + fi + # github_release == 'true' + draft_github_release: + permissions: + # needed to create draft release + contents: write + needs: [approval] + if: always() && !cancelled() && !failure() && github.event.inputs.github_release == 'true' + uses: ./.github/workflows/stage_release.yml + secrets: inherit diff --git a/.github/workflows/security_checks.yml b/.github/workflows/security_checks.yml new file mode 100644 index 000000000..8677bea07 --- /dev/null +++ b/.github/workflows/security_checks.yml @@ -0,0 +1,49 @@ +name: "[auto] Security Checks" +on: + workflow_call: + inputs: + continue-on-error: + type: boolean + required: false + default: false + +jobs: + sdl: + continue-on-error: ${{ inputs.continue-on-error }} + name: SDL Compliance Checks + runs-on: windows-latest + permissions: + # needed to write security info to repository + security-events: write + contents: read + steps: + - uses: actions/checkout@v4 + + # Install dotnet, used by MSDO + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 5.0.x + 6.0.x + + # Run analyzers + - name: Run Microsoft Security DevOps Analysis + uses: microsoft/security-devops-action@v1 + id: msdo + env: + # file path to analyze + GDN_BANDIT_TARGET: 'azext_iot' + GDN_BANDIT_RECURSIVE: true + + # Upload alerts to the Security tab + - name: Upload alerts to Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.msdo.outputs.sarifFile }} + + # Upload alerts file as a workflow artifact + - name: Upload alerts artifact + uses: actions/upload-artifact@v4 + with: + name: alerts + path: ${{ steps.msdo.outputs.sarifFile }} diff --git a/.github/workflows/stage_release.yml b/.github/workflows/stage_release.yml new file mode 100644 index 000000000..7003b56fa --- /dev/null +++ b/.github/workflows/stage_release.yml @@ -0,0 +1,44 @@ +name: "[auto] Draft Github Release" + +on: + workflow_call: + +jobs: + create_draft_release: + runs-on: [self-hosted, 1ES.Pool=iotupx-iot-cli-github-hosted-pool, 1ES.ImageOverride=Ubuntu20.04Compliant] + permissions: + # needed to create a draft release + contents: write + steps: + - name: Download Wheel + uses: actions/download-artifact@v4 + with: + name: azure-iot-cli-ext + path: ./release + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Install and determine version + run: | + wheel=$(find ./release/*.whl) + pip install $wheel + version=$(pip show azure_iot | grep Version: | awk '{print $2}') + echo "wheel=$wheel" >> $GITHUB_ENV + echo "version=$version" >> $GITHUB_ENV + echo "tag=v$version" >> $GITHUB_ENV + - name: Download SBOM + uses: actions/download-artifact@v4 + with: + name: SBOM + path: ./release/SBOM + - name: Zip SBOM + run: zip ./SBOM.zip ./release/SBOM -r + - name: Create Release + run: | + echo tag: "${{ env.tag }}" + echo version: "${{ env.version }}" + echo wheel: "${{ env.wheel }}" + gh release create "${{ env.tag }}" --generate-notes -d -t "azure-iot ${{ env.version }}" "${{ env.wheel }}" "./SBOM.zip#SBOM" --repo "${{ github.repository }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 000000000..23c9d3c15 --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,64 @@ +name: Tox tests +on: + workflow_call: + inputs: + continue-on-error: + type: boolean + required: false + default: false + workflow_dispatch: + inputs: + continue-on-error: + type: boolean + required: false + default: false + +permissions: + contents: read + +jobs: + unit-test: + name: Unit test ${{ matrix.py }} - ${{ matrix.os }} + continue-on-error: ${{ inputs.continue-on-error }} + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: + - ubuntu + - windows + - macos + py: + - "3.11" + - "3.10" + - "3.9" + - "3.8" + steps: + - name: Setup python ${{ matrix.py }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.py }} + - uses: actions/checkout@v4 + - name: Install tox-gh + run: python -m pip install tox-gh + - name: Setup test suite + run: tox r -vv --notest + - name: Run test suite + run: tox r --skip-pkg-install + code-coverage: + name: Calculate code coverage + continue-on-error: true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run code coverage + run: | + python -m pip install tox + tox -e coverage + coverage=$(jq .totals.percent_covered coverage.json | cut -c1-4) + echo "Code coverage: $coverage%" >> $GITHUB_STEP_SUMMARY + - name: Upload code coverage + uses: actions/upload-artifact@v4 + with: + path: ./htmlcov + name: code_coverage diff --git a/dev_requirements b/dev_requirements index 33748846a..845c1a7c2 100644 --- a/dev_requirements +++ b/dev_requirements @@ -1,5 +1,5 @@ pytest -pytest-mock +pytest-mock==3.12.0 pytest-cov pytest-env uamqp~=1.2 diff --git a/tox.ini b/tox.ini index f9678ba8f..4cc2e357d 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ skip_missing_interpreters = true envlist = lint - python-azdev-unit + python-azcur-unit [base] distdir={toxworkdir}/build @@ -24,11 +24,10 @@ description = # int/unit determines test suites to run # list all available tox environments with: `tox -av` python: Local Python - py3.7: Python 3.7 - py3.8: Python 3.8 - py3.9: Python 3.9 - py3.10: Python 3.10 - py3.11: Python 3.11 + py38: Python 3.8 + py39: Python 3.9 + py310: Python 3.10 + py311: Python 3.11 azmin: min azure-cli azcur: current azure-cli azdev: dev azure-cli @@ -44,12 +43,15 @@ commands = flake8 azext_iot/ --statistics --config=setup.cfg pylint azext_iot/ --rcfile=.pylintrc -[testenv:py{thon,3.7,3.8,3.9,3.10,3.11}-az{min,cur,dev}-{int,unit}] +[testenv:py{thon,38,39,310,311}-az{min,cur,dev}-{int,unit}] skip_install = True -passenv = - AZEXT_IOT_TESTRG description = {[base]description} +setenv = + azext_iot_testrg=testrg + PYTHONPATH={envsitepackagesdir}/azure-cli-extensions/azure-iot +passenv = + azext_* deps = # base deps {[base]deps} @@ -59,7 +61,7 @@ deps = azdev: ../azure-cli/src/azure-cli azdev: ../azure-cli/src/azure-cli-core # azure cli test sdk - ../azure-cli/src/azure-cli-testsdk + azure-cli-testsdk commands = python --version # install to tox extension dir @@ -70,3 +72,87 @@ commands = # You can pass additional positional args to pytest using `-- {args}` unit: pytest -k _unit ./azext_iot/tests {posargs} int: pytest -k _int ./azext_iot/tests {posargs} + +# tox-gh matrix (github action -> tox python environment) +[gh] +python = + 3.11 = py311-azcur-unit + 3.10 = py310-azcur-unit + 3.9 = py39-azcur-unit + 3.8 = lint, py38-azmin-unit + +# tests to be run in integration pipeline +[testenv:{Central,ADT,DPS,Hub1,Hub2,ADU}-int] +skip_install = True +passenv = + AZURE_* + azext_* +description = + Central: IoT Central + ADT: Digital Twin + DPS: DPS + Hub1: IoT Hub certificate, config, core, jobs, state + Hub2: IoT Hub devices, message endpoints, messaging, and modules + ADU: ADU + {[base]description} +deps = + # base deps + {[base]deps} + azure-cli==2.58.0 + # azure cli test sdk + azure-cli-testsdk +setenv = + AZURE_TEST_RUN_LIVE=True + PYTHONPATH={envsitepackagesdir}/azure-cli-extensions/azure-iot +commands = + python --version + # install to tox extension dir + pip install -U --target {envsitepackagesdir}/azure-cli-extensions/azure-iot . + # validate az and extension version + az -v + # run tests + # You can pass additional positional args to pytest using `-- {args}` + + Central: pytest -vv -k _int.py ./azext_iot/tests/central \ + Central: --dist=loadfile -n 7 --reruns 2 --reruns-delay 60 \ + Central: --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml {posargs} + + ADT: pytest -vv -k _int.py ./azext_iot/tests/digitaltwins \ + ADT: --dist=loadfile -n 7 --reruns 2 --reruns-delay 60 \ + ADT: --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml {posargs} + + DPS: pytest -vv -k _int.py ./azext_iot/tests/dps --dist=loadfile \ + DPS: -n 7 --reruns 2 --reruns-delay 60 \ + DPS: --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml {posargs} + + Hub1: pytest -vv -k _int.py ./azext_iot/tests/iothub/certificate ./azext_iot/tests/iothub/configurations \ + Hub1: ./azext_iot/tests/iothub/core ./azext_iot/tests/iothub/jobs ./azext_iot/tests/iothub/state/ \ + Hub1: --dist=loadfile -n 7 --reruns 2 --reruns-delay 60 \ + Hub1: --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml \ + Hub1: --deselect=azext_iot/tests/iothub/core/test_iothub_discovery_int.py::TestIoTHubDiscovery::test_iothub_targets \ + Hub1: --durations=0 {posargs} + + Hub2: pytest -vv -k _int.py ./azext_iot/tests/iothub/devices ./azext_iot/tests/iothub/messaging \ + Hub2: ./azext_iot/tests/iothub/modules ./azext_iot/tests/iothub/message_endpoint \ + Hub2: --dist=loadfile -n 7 --reruns 2 --reruns-delay 60 \ + Hub2: --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml \ + Hub2: --deselect=azext_iot/tests/iothub/core/test_iothub_discovery_int.py::TestIoTHubDiscovery::test_iothub_targets \ + Hub2: --durations=0 {posargs} + + ADU: pytest -vv -k _int.py ./azext_iot/tests/deviceupdate \ + ADU: --dist=loadfile -n 7 --reruns 0 --reruns-delay 60 \ + ADU: --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml {posargs} + +[testenv:coverage] +description = run code coverage +setenv = + azext_iot_testrg=testrg + PYTHONPATH={envsitepackagesdir}/azure-cli-extensions/azure-iot +deps = + {[base]deps} + azure-cli + azure-cli-testsdk +commands = + # install to tox extension dir due to issue loading azext_iot/tests/deviceupdate/test_adu_loader_int.py + pip install -U --target {envsitepackagesdir}/azure-cli-extensions/azure-iot . + pytest -k _unit.py --cov=azext_iot --cov-report=json --cov-report=html ./azext_iot/tests \ No newline at end of file