chore(release): version packages #3169
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: test | |
| # Heavy suites run independently so Go, driver and browser work do not queue | |
| # behind one another. The final job keeps the historical required-check name. | |
| on: | |
| pull_request: | |
| branches: [master] | |
| push: | |
| branches: [master] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: test-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| changes: | |
| name: classify changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| core: ${{ steps.paths.outputs.core }} | |
| web: ${{ steps.paths.outputs.web }} | |
| drivers: ${{ steps.paths.outputs.drivers }} | |
| compose: ${{ steps.paths.outputs.compose }} | |
| base_sha: ${{ steps.paths.outputs.base_sha }} | |
| head_sha: ${{ steps.paths.outputs.head_sha }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - id: paths | |
| name: Classify changed paths | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| PR_TITLE: ${{ github.event.pull_request.title }} | |
| COMMIT_MESSAGE: ${{ github.event.head_commit.message }} | |
| BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} | |
| HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| run: | | |
| set -euo pipefail | |
| core=false | |
| web=false | |
| drivers=false | |
| compose=false | |
| if { [ "${EVENT_NAME}" = pull_request ] && [[ "${PR_TITLE}" == "chore(release): version packages"* ]]; } || \ | |
| { [ "${EVENT_NAME}" = push ] && [[ "${COMMIT_MESSAGE}" == "chore(release): version packages"* ]]; }; then | |
| echo "Generated Version Packages change; skipping implementation suites." | |
| else | |
| if [ -z "${BASE_SHA}" ] || [[ "${BASE_SHA}" =~ ^0+$ ]]; then | |
| BASE_SHA="$(git rev-parse HEAD^)" | |
| fi | |
| mapfile -t changed_paths < <(git diff --name-only "${BASE_SHA}"..."${HEAD_SHA}") | |
| printf '%s\n' "${changed_paths[@]}" | bash .github/scripts/classify-test-changes.sh > "${RUNNER_TEMP}/test-paths" | |
| while IFS='=' read -r key value; do | |
| case "${key}" in | |
| core) core="${value}" ;; | |
| web) web="${value}" ;; | |
| drivers) drivers="${value}" ;; | |
| compose) compose="${value}" ;; | |
| esac | |
| done < "${RUNNER_TEMP}/test-paths" | |
| fi | |
| { | |
| echo "core=${core}" | |
| echo "web=${web}" | |
| echo "drivers=${drivers}" | |
| echo "compose=${compose}" | |
| echo "base_sha=${BASE_SHA}" | |
| echo "head_sha=${HEAD_SHA}" | |
| } >> "${GITHUB_OUTPUT}" | |
| core: | |
| name: core (Go) | |
| needs: changes | |
| if: needs.changes.outputs.core == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version: '1.26' | |
| cache-dependency-path: go/go.sum | |
| - name: Vet | |
| working-directory: go | |
| run: go vet ./... | |
| # drivers/ is gitignored and fetched from the pin, so a checkout alone | |
| # leaves it empty. Anything that loads a driver needs this first. | |
| - name: Fetch the bundled drivers | |
| run: make drivers | |
| - name: Test | |
| working-directory: go | |
| # Embedded database fixtures make API/state packages exceed two | |
| # minutes on shared runners. Keep a finite package deadline; focused | |
| # tests still check queue latency, cancellation and operation limits. | |
| run: go test -count=1 -timeout 300s ./... | |
| web: | |
| name: web | |
| needs: changes | |
| if: needs.changes.outputs.web == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - run: npm ci --prefer-offline --no-audit --no-fund | |
| - run: npm test | |
| # Proves the cutover before it happens: throw the committed drivers away, | |
| # fetch them from the pin alone, and run the suites that read them. While | |
| # the files are still committed this is redundant with the drift check. The | |
| # moment they stop being committed it is the only thing standing between a | |
| # fresh clone and a build that cannot find a driver. | |
| drivers-from-pin: | |
| name: drivers build from the pin alone | |
| needs: changes | |
| if: needs.changes.outputs.drivers == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version: '1.26' | |
| cache-dependency-path: go/go.sum | |
| - name: Discard the committed snapshot | |
| run: rm -f drivers/*.lua | |
| # This is the fresh-clone path itself: drivers-present is what `make | |
| # test`, `make e2e` and `make release` depend on, and on an empty | |
| # drivers/ it fetches the snapshot rather than stopping to say how. | |
| - name: The presence guard materialises the snapshot | |
| run: | | |
| make drivers-present | |
| want="$(jq -r '.drivers | length' drivers/BUNDLED_SOURCE.json)" | |
| got="$(ls drivers/*.lua | wc -l | tr -d ' ')" | |
| echo "guard fetched ${got} of ${want}" | |
| [ "$want" = "$got" ] \ | |
| || { echo "::error::guard left ${got} of ${want} drivers"; exit 1; } | |
| # Idempotent by design: asking again writes the same bytes. | |
| - name: Fetch from the pin | |
| run: make drivers | |
| - name: Every pinned driver came back | |
| run: | | |
| want="$(jq -r '.drivers | length' drivers/BUNDLED_SOURCE.json)" | |
| got="$(ls drivers/*.lua | wc -l | tr -d ' ')" | |
| echo "pinned ${want}, fetched ${got}" | |
| [ "$want" = "$got" ] || { echo "::error::fetched ${got} of ${want}"; exit 1; } | |
| # The fetched files land at the same path the committed ones did, so | |
| # nothing that reads drivers/ needs to know where they came from. | |
| - name: The driver suites pass against the fetched copies | |
| working-directory: go | |
| run: go test ./internal/drivers/... ./internal/driverinventory/... | |
| - name: They are byte-for-byte what was committed | |
| run: | | |
| git diff --exit-code -- drivers/ \ | |
| || { echo "::error::fetched snapshot differs from the committed one"; exit 1; } | |
| echo "identical" | |
| drivers: | |
| name: drivers | |
| needs: changes | |
| if: needs.changes.outputs.drivers == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version: '1.26' | |
| cache-dependency-path: go/go.sum | |
| # drivers/ is gitignored and fetched from the pin, so a checkout alone | |
| # leaves it empty. Anything that loads a driver needs this first. | |
| - name: Fetch the bundled drivers | |
| run: make drivers | |
| # drivers/ is a snapshot of srcfl/device-drivers, not a source. Hand- | |
| # editing a file here made this a second source of truth and the two | |
| # drifted: a Sungrow fix landed upstream while the bundled copy kept the | |
| # bug that took a customer's inverter offline. | |
| - name: No driver source is committed here | |
| run: bash scripts/sync-bundled-drivers.sh --check | |
| - name: Validate publication | |
| working-directory: go | |
| run: >- | |
| go run ./cmd/ftw-driver-repository publish | |
| -unsigned -drivers ../drivers | |
| -output "${RUNNER_TEMP}/ftw-driver-publication" | |
| -base-url https://example.invalid/releases/download/drivers-ci | |
| -repository https://github.com/${GITHUB_REPOSITORY} | |
| -commit "${GITHUB_SHA}" | |
| - name: Require version bumps | |
| working-directory: go | |
| run: >- | |
| go run ./cmd/ftw-driver-repository check-versions | |
| -repo-root .. | |
| -base "${{ needs.changes.outputs.base_sha }}" | |
| # The check above diffs this repository's history. What reaches a | |
| # gateway is decided by where the pin points, so this asks the same | |
| # question of the pins -- and keeps asking it once the .lua files stop | |
| # being committed here. Quiet on a pull request that does not move it. | |
| - name: Require version bumps across the pin | |
| run: bash scripts/check-driver-versions.sh "${{ needs.changes.outputs.base_sha }}" | |
| -head "${{ needs.changes.outputs.head_sha }}" | |
| device-support-contract: | |
| name: Device Support driver contract | |
| needs: changes | |
| if: needs.changes.outputs.core == 'true' | |
| runs-on: ubuntu-latest | |
| env: | |
| DEVICE_SUPPORT_TOKEN: ${{ secrets.SOURCEFUL_CI_REPO_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: astral-sh/setup-uv@v7 | |
| if: env.DEVICE_SUPPORT_TOKEN != '' | |
| with: | |
| python-version: '3.12' | |
| - id: baseline | |
| name: Read pinned Device Support baseline | |
| run: | | |
| baseline=go/internal/driverrepo/testdata/device-support-baseline.json | |
| for key in repository commit driver version; do | |
| echo "${key}=$(jq -r ".${key}" "${baseline}")" >> "${GITHUB_OUTPUT}" | |
| done | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version: '1.26' | |
| cache-dependency-path: go/go.sum | |
| - name: Verify pinned Python-signed fixture and FTW Lua target | |
| working-directory: go | |
| env: | |
| FTW_DEVICE_SUPPORT_INDEX: ${{ github.workspace }}/go/internal/driverrepo/testdata/device-support-v1/index.envelope.json | |
| FTW_DEVICE_SUPPORT_PACKAGE: ${{ github.workspace }}/go/internal/driverrepo/testdata/device-support-v1/manifest.envelope.json | |
| FTW_DEVICE_SUPPORT_ARTIFACT_DIR: ${{ github.workspace }}/go/internal/driverrepo/testdata/device-support-v1 | |
| run: | | |
| public_key="$(tr -d '\n' < internal/driverrepo/testdata/device-support-v1/public.raw.b64)" | |
| FTW_DEVICE_SUPPORT_PUBLIC_KEY="${public_key}" \ | |
| go test -count=1 ./internal/driverrepo -run '^TestDeviceSupportPythonContract$' | |
| - name: Check out canonical Device Support package source | |
| if: env.DEVICE_SUPPORT_TOKEN != '' | |
| uses: actions/checkout@v7 | |
| with: | |
| repository: ${{ steps.baseline.outputs.repository }} | |
| ref: ${{ steps.baseline.outputs.commit }} | |
| path: .device-support | |
| token: ${{ env.DEVICE_SUPPORT_TOKEN }} | |
| - name: Build and sign the canonical SDM630 package and index from source | |
| if: env.DEVICE_SUPPORT_TOKEN != '' | |
| env: | |
| DRIVER: ${{ steps.baseline.outputs.driver }} | |
| VERSION: ${{ steps.baseline.outputs.version }} | |
| SOURCE_COMMIT: ${{ steps.baseline.outputs.commit }} | |
| run: | | |
| set -euo pipefail | |
| output="${RUNNER_TEMP}/device-support-package" | |
| mkdir -p "${output}" | |
| uv run --python 3.12 --with cryptography python - <<'PY' | |
| import base64 | |
| from pathlib import Path | |
| from cryptography.hazmat.primitives import serialization | |
| from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | |
| output = Path("${{ runner.temp }}") / "device-support-package" | |
| key = Ed25519PrivateKey.generate() | |
| (output / "private.pem").write_bytes(key.private_bytes( | |
| serialization.Encoding.PEM, | |
| serialization.PrivateFormat.PKCS8, | |
| serialization.NoEncryption(), | |
| )) | |
| (output / "public.pem").write_bytes(key.public_key().public_bytes( | |
| serialization.Encoding.PEM, | |
| serialization.PublicFormat.SubjectPublicKeyInfo, | |
| )) | |
| (output / "public.raw.b64").write_text(base64.b64encode( | |
| key.public_key().public_bytes( | |
| serialization.Encoding.Raw, | |
| serialization.PublicFormat.Raw, | |
| ) | |
| ).decode()) | |
| PY | |
| source_date_epoch="$(git -C .device-support show -s --format=%ct "${SOURCE_COMMIT}")" | |
| uv run --python 3.12 --with cryptography --with jsonschema --with referencing \ | |
| python .device-support/tools/driver_package.py package \ | |
| --source ".device-support/packages/v1/${DRIVER}/package-source.json" \ | |
| --repo-root .device-support \ | |
| --output-dir "${output}/artifacts" \ | |
| --base-url "https://packages.example/${DRIVER}/${VERSION}" \ | |
| --source-commit "${SOURCE_COMMIT}" \ | |
| --source-date-epoch "${source_date_epoch}" \ | |
| --key "${output}/private.pem" \ | |
| --key-id sourceful-test-1 | |
| uv run --python 3.12 --with cryptography --with jsonschema --with referencing \ | |
| python .device-support/tools/driver_package.py index \ | |
| --package-envelope "${output}/artifacts/manifest.envelope.json" \ | |
| --package-url "https://packages.example/${DRIVER}/${VERSION}/manifest.envelope.json" \ | |
| --channel beta \ | |
| --source-date-epoch "${source_date_epoch}" \ | |
| --public-key "${output}/public.pem" \ | |
| --key "${output}/private.pem" \ | |
| --key-id sourceful-test-1 \ | |
| --output "${output}/index.envelope.json" | |
| - name: Verify source-built Python signatures, package binding and FTW Lua target in Go | |
| if: env.DEVICE_SUPPORT_TOKEN != '' | |
| working-directory: go | |
| env: | |
| FTW_DEVICE_SUPPORT_INDEX: ${{ runner.temp }}/device-support-package/index.envelope.json | |
| FTW_DEVICE_SUPPORT_PACKAGE: ${{ runner.temp }}/device-support-package/artifacts/manifest.envelope.json | |
| FTW_DEVICE_SUPPORT_ARTIFACT_DIR: ${{ runner.temp }}/device-support-package/artifacts | |
| run: | | |
| public_key="$(tr -d '\n' < "${{ runner.temp }}/device-support-package/public.raw.b64")" | |
| FTW_DEVICE_SUPPORT_PUBLIC_KEY="${public_key}" \ | |
| go test -count=1 ./internal/driverrepo -run '^TestDeviceSupportPythonContract$' | |
| compose: | |
| name: module boundaries | |
| needs: changes | |
| if: needs.changes.outputs.compose == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - run: make compose-migration-test container-boundary-test | |
| - run: bash .github/scripts/test-change-classifier.sh | |
| # contract/registry.yaml is one file that lives in this repository and in | |
| # srcfl/ftw-webapp, and only a job with both checked out can tell whether it | |
| # still is one file. The header has always claimed CI fails when the two | |
| # drift; nothing compared them, and they drifted three ways — a code each | |
| # side had that the other had never heard of, and a retryable flag that | |
| # disagreed in the direction that decides what a phone offers a user. | |
| # | |
| # Not gated on changed paths. The whole failure was a check that ran only | |
| # sometimes, and the app can change its copy without a file here moving. | |
| contract: | |
| name: the registry has not drifted from the app | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # A change that lands in both repositories at once cannot be compared | |
| # against the other one's default branch: the counterpart is not there | |
| # yet, and each side would wait for the other forever. A pull request | |
| # that names where its pair lives is compared against that instead. | |
| # Everything else — a stray edit, a rename, a copy someone forgot — still | |
| # meets the default branch, which is the case this job exists for. | |
| - name: Which copy of the app to compare against | |
| id: pair | |
| env: | |
| # Read live rather than from github.event: that payload is a snapshot | |
| # taken when the run was queued, so a pair declared after the last | |
| # push would be invisible and the job would compare against the wrong | |
| # branch while looking like it had worked. | |
| GH_TOKEN: ${{ github.token }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| set -euo pipefail | |
| REF=main | |
| if [ "${EVENT_NAME}" = pull_request ]; then | |
| BODY=$(gh pr view "${PULL_REQUEST_NUMBER}" \ | |
| --repo "${{ github.repository }}" --json body -q .body) | |
| PAIR_REF=$(printf '%s\n' "$BODY" | sed -n 's|^Contract-pair: *srcfl/ftw-webapp@||p' | head -1 | tr -d '\r') | |
| if [ -n "${PAIR_REF}" ]; then | |
| REF="${PAIR_REF}" | |
| fi | |
| fi | |
| echo "ref=${REF}" >> "$GITHUB_OUTPUT" | |
| echo "comparing against srcfl/ftw-webapp@${REF}" | |
| # One file, not the app's whole history. | |
| - name: Check out the app | |
| uses: actions/checkout@v7 | |
| with: | |
| repository: srcfl/ftw-webapp | |
| ref: ${{ steps.pair.outputs.ref }} | |
| path: .app | |
| token: ${{ secrets.FTW_CONTRACT_TOKEN || github.token }} | |
| sparse-checkout: | | |
| contract/registry.yaml | |
| contract/push-catalogue.yaml | |
| sparse-checkout-cone-mode: false | |
| - name: Compare the two copies | |
| run: | | |
| set -euo pipefail | |
| # The registry and the push catalogue are paired the same way: one | |
| # file each, living in both repositories. The catalogue carries every | |
| # sentence the box may put on a lock screen — the app's prose, which | |
| # the box renders only because a push arrives when the app is not | |
| # running to render it itself. | |
| for file in contract/registry.yaml contract/push-catalogue.yaml; do | |
| # A missing checkout is not agreement. Said out loud here because | |
| # the obvious way to write this step — diff and hope — passes | |
| # silently when the path is wrong, which is exactly how the last | |
| # guard failed. | |
| if [ ! -f ".app/$file" ]; then | |
| echo "the app's copy is not at .app/$file." | |
| echo "the checkout above failed or moved; this check will not guess." | |
| exit 1 | |
| fi | |
| if ! diff -u "$file" ".app/$file"; then | |
| echo | |
| echo "This file is one file in two repositories. Decide which side" | |
| echo "is right by reading what each side's code does, then change" | |
| echo "both copies in the same pair of pull requests — and rerun" | |
| echo "go generate ./internal/... here." | |
| exit 1 | |
| fi | |
| done | |
| echo "byte for byte the same files" | |
| e2e: | |
| name: full stack | |
| needs: changes | |
| if: needs.changes.outputs.core == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-go@v7 | |
| with: | |
| go-version: '1.26' | |
| cache-dependency-path: go/go.sum | |
| # drivers/ is gitignored and fetched from the pin, so a checkout alone | |
| # leaves it empty. Anything that loads a driver needs this first. | |
| - name: Fetch the bundled drivers | |
| run: make drivers | |
| - run: make e2e | |
| test: | |
| name: go test + vet | |
| if: always() | |
| needs: | |
| [changes, core, web, drivers, device-support-contract, compose, e2e, contract] | |
| runs-on: ubuntu-latest | |
| env: | |
| RESULTS: >- | |
| ${{ needs.changes.result }} ${{ needs.core.result }} | |
| ${{ needs.web.result }} | |
| ${{ needs.drivers.result }} ${{ needs.device-support-contract.result }} | |
| ${{ needs.compose.result }} | |
| ${{ needs.e2e.result }} ${{ needs.contract.result }} | |
| steps: | |
| - name: Require every selected suite to pass | |
| run: | | |
| case " ${RESULTS} " in | |
| *" failure "*|*" cancelled "*) exit 1 ;; | |
| esac | |
| echo "Selected suites passed: ${RESULTS}" |