Skip to content

Merge pull request #1217 from srcfl/licensing/agpl-home-use-20260912 #3168

Merge pull request #1217 from srcfl/licensing/agpl-home-use-20260912

Merge pull request #1217 from srcfl/licensing/agpl-home-use-20260912 #3168

Workflow file for this run

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}"