8. Add tools/deploy_edge_drivers.py
Create:
tools/deploy_edge_drivers.py
#!/usr/bin/env python3
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
from pathlib import Path
from typing import Any
import requests
import yaml
API_VERSION = "20200810"
DRIVERS_ROOT = Path("drivers")
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
def fail(message: str) -> None:
print(f"ERROR: {message}", file=sys.stderr)
sys.exit(1)
def require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
fail(f"Missing required environment variable: {name}")
return value
def run_git(args: list[str]) -> str:
result = subprocess.run(
["git", *args],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return result.stdout.strip()
def api_headers(token: str, content_type: str | None = None) -> dict[str, str]:
headers = {
"Accept": f"application/vnd.smartthings+json;v={API_VERSION}",
"Authorization": f"Bearer {token}",
"X-ST-LOG-LEVEL": "DEBUG",
"X-ST-CORRELATION": f"edge-driver-ci-{int(time.time())}",
}
if content_type:
headers["Content-Type"] = content_type
return headers
def request_with_retry(method: str, url: str, *, max_retries: int = 3, **kwargs: Any) -> requests.Response:
attempt = 0
while True:
response = requests.request(method, url, timeout=60, **kwargs)
if response.status_code not in RETRY_STATUS_CODES or attempt >= max_retries:
return response
delay = 10 * (attempt + 1)
print(f"{method} {url} returned HTTP {response.status_code}; retrying in {delay}s")
time.sleep(delay)
attempt += 1
def find_all_drivers() -> list[Path]:
if not DRIVERS_ROOT.exists():
fail("No drivers/ directory found")
drivers: list[Path] = []
for config in DRIVERS_ROOT.glob("*/*/config.yml"):
drivers.append(config.parent)
return sorted(drivers)
def resolve_changed_drivers() -> list[Path]:
all_drivers = find_all_drivers()
if os.environ.get("DEPLOY_ALL", "").lower() == "true":
return all_drivers
override = os.environ.get("DRIVER_PATHS")
if override:
try:
raw_paths = json.loads(override)
if not isinstance(raw_paths, list):
fail("DRIVER_PATHS must be a JSON list")
wanted = {Path(p).as_posix().rstrip("/") for p in raw_paths}
except json.JSONDecodeError:
wanted = {p.strip().rstrip("/") for p in override.split(",") if p.strip()}
selected = [p for p in all_drivers if p.as_posix() in wanted or p.name in wanted]
if not selected:
fail(f"DRIVER_PATHS did not match any driver directory: {override}")
return selected
base_sha = os.environ.get("BASE_SHA")
head_sha = os.environ.get("HEAD_SHA", "HEAD")
if not base_sha or set(base_sha) == {"0"}:
try:
base_sha = run_git(["rev-list", "--max-parents=0", "HEAD"])
except subprocess.CalledProcessError:
base_sha = "HEAD~1"
try:
changed_files = run_git(["diff", "--name-only", base_sha, head_sha]).splitlines()
except subprocess.CalledProcessError as exc:
print(exc.stderr, file=sys.stderr)
fail("Could not determine changed files. Use DEPLOY_ALL=true or DRIVER_PATHS.")
changed_driver_dirs: set[Path] = set()
for filename in changed_files:
path = Path(filename)
if len(path.parts) >= 3 and path.parts[0] == "drivers":
candidate = Path(path.parts[0]) / path.parts[1] / path.parts[2]
if (candidate / "config.yml").exists():
changed_driver_dirs.add(candidate)
return sorted(changed_driver_dirs)
def read_package_key(driver_dir: Path) -> str:
config_path = driver_dir / "config.yml"
with config_path.open("r", encoding="utf-8") as f:
config = yaml.safe_load(f)
package_key = config.get("packageKey") if isinstance(config, dict) else None
if not package_key:
fail(f"{config_path} does not contain packageKey")
return str(package_key)
def should_include_in_zip(path: Path) -> bool:
if path.is_dir():
return False
parts = set(path.parts)
name = path.name
if "__pycache__" in parts:
return False
if ".git" in parts:
return False
if name.endswith(".zip"):
return False
if "test" in parts or "tests" in parts:
return False
if name in {"config.yml", "fingerprints.yml", "search-parameters.yml", "search-parameters.yaml"}:
return True
if path.suffix in {".lua", ".pem", ".crt"}:
return True
if len(path.parts) >= 2 and path.parts[0] == "profiles" and path.suffix in {".yml", ".yaml"}:
return True
return False
def package_driver(driver_dir: Path, output_dir: Path) -> Path:
zip_path = output_dir / f"{driver_dir.name}.zip"
required = ["config.yml"]
for filename in required:
if not (driver_dir / filename).exists():
fail(f"{driver_dir} is missing required file {filename}")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(driver_dir.rglob("*")):
relative = path.relative_to(driver_dir)
if should_include_in_zip(relative):
archive.write(path, relative.as_posix())
if zip_path.stat().st_size == 0:
fail(f"Created empty ZIP for {driver_dir}")
return zip_path
def get_channel_drivers(environment_url: str, channel_id: str, token: str) -> dict[str, dict[str, str]]:
url = f"{environment_url}/channels/{channel_id}/drivers"
response = request_with_retry("GET", url, headers=api_headers(token))
if response.status_code != 200:
print(response.text, file=sys.stderr)
fail("Failed to retrieve current channel drivers. Aborting because bulk update could remove drivers.")
existing: dict[str, dict[str, str]] = {}
for item in response.json().get("items", []):
driver_id = item.get("driverId")
version = item.get("version")
if not driver_id or not version:
continue
search_url = f"{environment_url}/drivers/search"
search_response = request_with_retry(
"POST",
search_url,
headers=api_headers(token, "application/json"),
json={"driverId": driver_id, "driverVersion": version},
)
if search_response.status_code != 200:
print(search_response.text, file=sys.stderr)
fail(f"Failed to retrieve packageKey for existing channel driver {driver_id}/{version}")
items = search_response.json().get("items", [])
if not items:
fail(f"drivers/search returned no details for existing channel driver {driver_id}/{version}")
package_key = items[0].get("packageKey")
if package_key:
existing[package_key] = {"driverId": driver_id, "version": version}
return existing
def upload_driver(environment_url: str, token: str, driver_dir: Path, zip_path: Path) -> dict[str, str]:
upload_url = f"{environment_url}/drivers/package"
with zip_path.open("rb") as f:
response = request_with_retry(
"POST",
upload_url,
headers=api_headers(token, "application/zip"),
data=f.read(),
)
if response.status_code != 200:
print(response.text, file=sys.stderr)
fail(f"Failed to upload {driver_dir}")
data = response.json()
driver_id = data.get("driverId")
version = data.get("version")
if not driver_id or not version:
fail(f"Upload response for {driver_dir} did not contain driverId and version")
return {"driverId": driver_id, "version": version}
def bulk_update_channel(environment_url: str, channel_id: str, token: str, drivers_by_package: dict[str, dict[str, str]]) -> None:
update_url = f"{environment_url}/channels/{channel_id}/drivers/bulk"
payload = [
{"driverId": info["driverId"], "version": info["version"]}
for _, info in sorted(drivers_by_package.items())
]
if not payload:
fail("Bulk update payload is empty. Refusing to update channel.")
print(f"Updating channel with {len(payload)} driver/version assignments.")
response = request_with_retry(
"PUT",
update_url,
headers=api_headers(token, "application/json"),
data=json.dumps(payload),
)
if response.status_code != 204:
print(response.text, file=sys.stderr)
fail(f"Failed to bulk update channel; HTTP {response.status_code}")
print("Channel bulk update succeeded.")
def main() -> None:
token = require_env("TOKEN")
channel_id = require_env("SMARTTHINGS_CHANNEL_ID")
environment_url = os.environ.get("SMARTTHINGS_ENVIRONMENT_URL", "https://api.smartthings.com").rstrip("/")
changed_drivers = resolve_changed_drivers()
if not changed_drivers:
print("No changed driver directories detected. Nothing to deploy.")
return
print("Drivers selected for deployment:")
for driver in changed_drivers:
print(f" - {driver}")
drivers_by_package = get_channel_drivers(environment_url, channel_id, token)
print(f"Read {len(drivers_by_package)} existing channel driver assignments.")
with tempfile.TemporaryDirectory() as tmp:
output_dir = Path(tmp)
for driver_dir in changed_drivers:
package_key = read_package_key(driver_dir)
zip_path = package_driver(driver_dir, output_dir)
print(f"Uploading {driver_dir} packageKey={package_key}")
uploaded = upload_driver(environment_url, token, driver_dir, zip_path)
drivers_by_package[package_key] = uploaded
print(f"Uploaded {driver_dir.name}: {uploaded['driverId']} / {uploaded['version']}")
bulk_update_channel(environment_url, channel_id, token, drivers_by_package)
if __name__ == "__main__":
main()
Why the script first reads the existing channel assignments:
The SmartThings PUT /channels/{channelId}/drivers/bulk call should be treated as replacing the channel’s desired driver/version assignment list. If you send only the newly changed driver, other drivers can be removed from the channel. This script preserves existing channel entries and replaces only those package keys that were just uploaded.
9. Add the GitHub Actions workflow
Create:
.github/workflows/deploy-edge-drivers.yml
name: Deploy SmartThings Edge Drivers
on:
push:
branches: [ main ]
paths:
- 'drivers/**'
- 'tools/**'
- '.github/workflows/deploy-edge-drivers.yml'
workflow_dispatch:
inputs:
deploy_all:
description: 'Deploy all drivers instead of only changed drivers'
type: boolean
default: false
schedule:
# Keeps the SmartThings refresh token alive and rotated even during quiet repo periods.
# GitHub cron is UTC.
- cron: '17 3 */14 * *'
permissions:
contents: read
concurrency:
group: smartthings-edge-driver-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
env:
SMARTTHINGS_ENVIRONMENT_URL: https://api.smartthings.com
SMARTTHINGS_CHANNEL_ID: ${{ secrets.SMARTTHINGS_CHANNEL_ID }}
BASE_SHA: ${{ github.event.before }}
HEAD_SHA: ${{ github.sha }}
DEPLOY_ALL: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.deploy_all) }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests pyyaml
- name: Refresh SmartThings access token
run: python tools/refresh_smartthings_token.py
env:
SMARTTHINGS_REFRESH_TOKEN: ${{ secrets.SMARTTHINGS_REFRESH_TOKEN }}
SMARTTHINGS_CLIENT_ID: ${{ secrets.SMARTTHINGS_CLIENT_ID }}
SMARTTHINGS_CLIENT_SECRET: ${{ secrets.SMARTTHINGS_CLIENT_SECRET }}
- name: Persist rotated SmartThings refresh token
if: hashFiles('.smartthings_new_refresh_token') != ''
run: |
gh secret set SMARTTHINGS_REFRESH_TOKEN \
--repo "$GITHUB_REPOSITORY" \
--body-file .smartthings_new_refresh_token
rm -f .smartthings_new_refresh_token
env:
GH_TOKEN: ${{ secrets.GH_SECRETS_PAT }}
- name: Deploy Edge Drivers
if: github.event_name != 'schedule'
run: python tools/deploy_edge_drivers.py
- name: Token refresh only
if: github.event_name == 'schedule'
run: echo "Scheduled run refreshed and persisted the SmartThings refresh token only."
This workflow does three different things depending on how it runs:
| Trigger |
Behavior |
push to main with changes under drivers/ |
Deploys changed drivers |
Manual workflow_dispatch |
Deploys changed drivers, or all drivers if deploy_all is selected |
| Scheduled run every 14 days |
Refreshes and persists the SmartThings refresh token only |
The scheduled run is important. SmartThings refresh tokens can expire if unused, so a repository with no driver changes for a month may otherwise lose its ability to deploy unattended.
10. Test the token refresh first
Before testing deployment, run the workflow manually with no driver change and inspect the logs.
Expected result:
SmartThings access token refreshed.
If SmartThings returned a rotated token, you should also see:
SmartThings refresh token rotated; wrote replacement token for secret update.
Then the GitHub secret update step should succeed.
If the secret update step fails, fix that before deploying drivers. Otherwise your pipeline may work once and fail later with invalid_grant.
11. Test deployment with one driver
Make a harmless driver change, for example a comment in a Lua file, and push it to main.
Then check:
- The workflow detects the driver directory.
- The ZIP upload succeeds.
- The channel bulk update returns success.
- The channel still contains all previously assigned drivers.
- The changed driver appears in the SmartThings channel with the newly uploaded version.
You can confirm the channel from the CLI:
smartthings edge:channels:drivers
or by using the SmartThings Developer tools you normally use for channel inspection.
12. Common failure cases
invalid_grant
Usually one of these:
- The refresh token was already used and replaced.
- The rotated refresh token was not stored back into GitHub Secrets.
- The token was unused for too long and expired.
- The authorization code was reused during the initial setup.
- The OAuth redirect URI in the token exchange does not exactly match the one used during authorization.
Fix: repeat the browser authorization flow and replace SMARTTHINGS_REFRESH_TOKEN.
401 Unauthorized
Usually one of these:
- Missing or malformed
TOKEN.
- SmartThings access token refresh failed.
- Wrong client ID/client secret.
- Token scopes do not include the required driver/channel permissions.
403 Forbidden
Usually one of these:
- The Samsung account used for OAuth does not own the target channel.
- The OAuth app did not request the necessary scopes.
- The GitHub token used as
GH_SECRETS_PAT cannot update repository secrets.
Bulk update removes drivers from the channel
This happens when the bulk-update request contains only the new driver instead of the complete desired driver list.
The included deployment script avoids this by first reading existing channel assignments, mapping them by packageKey, replacing changed package keys, and only then calling PUT /channels/{channelId}/drivers/bulk.
No drivers detected
Make sure your driver path looks like:
drivers/<partner-or-author>/<driver-name>/config.yml
For non-standard layouts, either adapt find_all_drivers() or set DRIVER_PATHS manually.
Example:
env:
DRIVER_PATHS: '["drivers/oceancircle09600/namron-panel-heater"]'
13. Security recommendations
Use a private repository if the workflow contains unpublished driver code or sensitive release automation.
Do not print tokens.
Use ::add-mask:: for any token that may appear in logs.
Keep GH_SECRETS_PAT as narrow as possible. A fine-grained token limited to one repository with Secrets: Read and write is preferable to a broad classic PAT.
Avoid running this deployment workflow on pull requests from forks. GitHub does not pass normal repository secrets to forked pull request workflows, and deploying unreviewed code would be unsafe anyway.
Use concurrency to prevent two deployment jobs from rotating the same refresh token at the same time.
Do not send a bulk channel update if reading the existing channel assignments fails.
14. Minimal manual fallback
If the automation breaks, you can still deploy manually with the CLI:
smartthings edge:drivers:package drivers/your-name/your-driver
smartthings edge:channels:assign
If the refresh token is lost or expired, repeat the OAuth browser authorization flow and replace the SMARTTHINGS_REFRESH_TOKEN GitHub secret.
15. References