How to build an automated CI/CD pipeline for Edge Drivers?

Hi everyone,

I’m trying to set up a fully automated CI/CD pipeline (via GitHub Actions) to build and publish SmartThings Edge drivers directly to my channel. However, I am facing two major blockers regarding authentication:

  1. PAT Expiration: As Personal Access Tokens (PATs) now expire in 24 hours, they are not viable for long-term or automated pipelines. Generating a new token every day manually defeats the purpose of CI/CD.
  2. CLI & OAuth limitations: The SmartThings CLI supports an interactive OAuth flow in the browser, but this requires manual user intervention and doesn’t seem to natively support headless/automated environments to get refresh tokens.

My question:
What is the recommended approach to achieve a true, fully automated deployment pipeline for Edge Drivers without running into constant authentication errors?

Is there a way to generate a long-lived token, use Service Accounts, or bypass the CLI so we can automate the packaging workflow?

Any help or examples of how you guys manage automated deployments would be greatly appreciated!

This:

And yes, you need a token. Use that short-lived token to get it up and running.

I don’t know if you are using Linux, but lines like os.environ.get('BRANCH') are reading the environment variables, set like this in bash: export BRANCH="main".

Be extremely careful - especially with the channel ID…

Edit: if you don’t know what Jenkins is, it’s time to read up on that.


The part in SmartThings CLI that does the driver packaging (creating a ZIP) and the upload does pretty much the same as the important part of deploy.py. They are both just “wrappers” or “frontends” for the API.

Asked the AI a simple question and it came up with a novel:

Automated CI/CD Pipeline for SmartThings Edge Drivers

Last verified: 2026-07-04

This guide describes a practical GitHub Actions deployment pipeline for SmartThings Edge Drivers using direct SmartThings API calls. It follows the same general pattern as the official SmartThings Edge Drivers repository: package a driver ZIP, upload it with the driver package API, then update a Driver Channel through the channel bulk-update API.

The most important caveat is token handling: this is only reliable if the rotating SmartThings refresh token is persisted after every refresh.


0. What this guide does and does not do

This guide covers:

  • Creating an OAuth-In App for SmartThings API access.
  • Getting the first OAuth authorization code and refresh token.
  • Refreshing the SmartThings access token in GitHub Actions.
  • Persisting the rotated refresh token back into GitHub Secrets.
  • Packaging SmartThings Edge Drivers in a GitHub repository.
  • Uploading changed driver packages.
  • Safely updating a Driver Channel without accidentally removing existing drivers.

This guide does not cover:

  • Creating custom capabilities.
  • Publishing production-certified drivers through SmartThings certification.
  • Installing drivers on a Hub after deployment.
  • Replacing the SmartThings CLI for normal local development.

For local development, the SmartThings CLI is still the most convenient tool.


1. Assumed repository layout

The example scripts assume a layout similar to the official Edge Drivers repository:

your-repo/
├── drivers/
│   └── your-name-or-org/
│       ├── driver-one/
│       │   ├── config.yml
│       │   ├── fingerprints.yml
│       │   ├── profiles/
│       │   └── src/
│       └── driver-two/
│           ├── config.yml
│           ├── fingerprints.yml
│           ├── profiles/
│           └── src/
├── tools/
│   ├── refresh_smartthings_token.py
│   └── deploy_edge_drivers.py
└── .github/
    └── workflows/
        └── deploy-edge-drivers.yml

Every deployable driver directory must contain a config.yml with a packageKey.


2. Prerequisites

You need:

  • A SmartThings Developer account.
  • The SmartThings CLI installed locally.
  • A SmartThings Driver Channel.
  • A GitHub repository containing one or more Edge Drivers.
  • A GitHub Actions-enabled repository.
  • Python 3.11 or newer in the workflow.
  • A GitHub token capable of updating repository secrets.

The last point matters because SmartThings refresh tokens rotate. The default GITHUB_TOKEN is not the right credential for updating repository secrets in this setup. Use one of these instead:

  • A fine-grained GitHub personal access token with Secrets: Read and write permission for this repository.
  • A classic GitHub PAT with repo scope for a private repository.
  • A GitHub App installation token with permission to update repository secrets.

Store this GitHub token as:

GH_SECRETS_PAT

3. Create or identify your SmartThings Driver Channel

Create your channel with the SmartThings CLI if you do not already have one:

smartthings edge:channels:create

Find the channel UUID:

smartthings edge:channels

Save the target channel UUID. You will later store it as:

SMARTTHINGS_CHANNEL_ID

Important: a channel locks each assigned driver to a specific uploaded version. Uploading a new driver package alone is not enough. The new version must also be assigned to the channel.


4. Create a SmartThings OAuth-In App

Create an OAuth-In App with the SmartThings CLI:

smartthings apps:create

Select:

OAuth-In App

Use a redirect URI that you can easily copy from the browser address bar, for example:

http://localhost:8765/callback

You do not need a running local web server for a manual one-time setup. After login, the browser may show an error because nothing is listening on that local port, but the address bar should still contain the authorization code.

When prompted for scopes, use the smallest useful set for driver deployment. For this guide, request:

r:drivers:* w:drivers:* r:channels:* w:channels:*

After creating the app, copy and securely store:

SMARTTHINGS_CLIENT_ID
SMARTTHINGS_CLIENT_SECRET

The client secret is only shown once.


5. Get the initial SmartThings refresh token

Set local shell variables:

export SMARTTHINGS_CLIENT_ID="your-client-id"
export SMARTTHINGS_CLIENT_SECRET="your-client-secret"
export SMARTTHINGS_REDIRECT_URI="http://localhost:8765/callback"
export SMARTTHINGS_SCOPES="r:drivers:* w:drivers:* r:channels:* w:channels:*"

Open the authorization URL:

python3 - <<'PY'
import os
import urllib.parse

params = {
    "client_id": os.environ["SMARTTHINGS_CLIENT_ID"],
    "response_type": "code",
    "redirect_uri": os.environ["SMARTTHINGS_REDIRECT_URI"],
    "scope": os.environ["SMARTTHINGS_SCOPES"],
}

print("https://api.smartthings.com/oauth/authorize?" + urllib.parse.urlencode(params))
PY

Log in with the Samsung account that owns the target channel. After the redirect, copy the code query parameter from the browser URL.

Then exchange the authorization code for tokens:

export SMARTTHINGS_AUTH_CODE="paste-the-code-here"

curl -sS \
  -u "${SMARTTHINGS_CLIENT_ID}:${SMARTTHINGS_CLIENT_SECRET}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=${SMARTTHINGS_AUTH_CODE}" \
  --data-urlencode "redirect_uri=${SMARTTHINGS_REDIRECT_URI}" \
  --data-urlencode "client_id=${SMARTTHINGS_CLIENT_ID}" \
  https://api.smartthings.com/oauth/token | jq .

Save the returned refresh_token.

You do not need to save the initial access_token; the workflow will create a fresh access token during every run.

Important: do not treat the refresh token as permanent. SmartThings refresh tokens can expire if not used, and practical implementations should assume that a new refresh token returned by the token endpoint replaces the old one.


6. Add GitHub Secrets

In GitHub, go to:

Repository → Settings → Secrets and variables → Actions → New repository secret

Create these secrets:

Secret name Value
SMARTTHINGS_CLIENT_ID OAuth-In App client ID
SMARTTHINGS_CLIENT_SECRET OAuth-In App client secret
SMARTTHINGS_REFRESH_TOKEN The latest SmartThings refresh token
SMARTTHINGS_CHANNEL_ID Your target Driver Channel UUID
GH_SECRETS_PAT GitHub token allowed to update repository secrets

The workflow uses GH_SECRETS_PAT only to replace SMARTTHINGS_REFRESH_TOKEN after SmartThings returns a rotated refresh token.


7. Add tools/refresh_smartthings_token.py

Create:

tools/refresh_smartthings_token.py
#!/usr/bin/env python3

import os
import sys
import requests
from pathlib import Path


TOKEN_URL = "https://api.smartthings.com/oauth/token"
ROTATED_TOKEN_FILE = Path(".smartthings_new_refresh_token")


def fail(message: str, response: requests.Response | None = None) -> None:
    print(f"ERROR: {message}", file=sys.stderr)
    if response is not None:
        print(f"HTTP {response.status_code}", file=sys.stderr)
        print(response.text, 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 write_github_env(name: str, value: str) -> None:
    github_env = os.environ.get("GITHUB_ENV")
    if not github_env:
        return

    # Access tokens are single-line values. Use the simple env-file format.
    with open(github_env, "a", encoding="utf-8") as f:
        f.write(f"{name}={value}\n")


def main() -> None:
    refresh_token = require_env("SMARTTHINGS_REFRESH_TOKEN")
    client_id = require_env("SMARTTHINGS_CLIENT_ID")
    client_secret = require_env("SMARTTHINGS_CLIENT_SECRET")

    response = requests.post(
        TOKEN_URL,
        auth=(client_id, client_secret),
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "refresh_token",
            "client_id": client_id,
            "refresh_token": refresh_token,
        },
        timeout=30,
    )

    if response.status_code != 200:
        fail("Failed to refresh SmartThings token", response)

    data = response.json()
    access_token = data.get("access_token")
    new_refresh_token = data.get("refresh_token")

    if not access_token:
        fail("Token refresh response did not contain access_token")

    print("::add-mask::" + access_token)
    write_github_env("TOKEN", access_token)

    if new_refresh_token and new_refresh_token != refresh_token:
        print("::add-mask::" + new_refresh_token)
        ROTATED_TOKEN_FILE.write_text(new_refresh_token, encoding="utf-8")
        print("SmartThings refresh token rotated; wrote replacement token for secret update.")
    else:
        print("SmartThings refresh token did not rotate in this response.")

    print("SmartThings access token refreshed.")


if __name__ == "__main__":
    main()

This script does three things:

  1. Refreshes the SmartThings access token.
  2. Exposes the new access token to later workflow steps as TOKEN.
  3. Writes a rotated refresh token to .smartthings_new_refresh_token if SmartThings returns one.

It intentionally does not print tokens to stdout.

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:

  1. The workflow detects the driver directory.
  2. The ZIP upload succeeds.
  3. The channel bulk update returns success.
  4. The channel still contains all previously assigned drivers.
  5. 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

Problem is that the AI is misleading by the smartthings lack of correct documentation :sweat_smile:

When creating the OAuth-In App you can’t select this scopes for drivers and channels. So, you can’t use the normal API OAuth Flow for CLI or driver automated publishing.

Looking at the CLI code, I managed to find the correct OAuth flow for token refreshing. I’m running some tests and will post the results later.

Maybe it’s better than no answer at all, and perhaps there’s something in there you didn’t know before. That’s the huge advantage of AI: it handles tasks people don’t really want to do. If you ask ten people a complex question, you get ten different answers, none of which are entirely correct.

For 99.9999% of people, knowing how to extract a token from SmartThings or how to renew it is useless. I have been developing software for 30 years - commercial software only when someone paid for it and unpaid work only when it is completely open source. SmartThings is completely proprietary, and if they decide tomorrow to lock out community developers, because they disrupt the business model, the time we’ve spent here will have been wasted anyway.

One more thing: if everyone were to put their driver into your repository, you’d end up with multiple Matter switch drivers sitting side-by-side, for example. It would make much more sense to merge these drivers - creating a single, comprehensive community Matter switch driver or Zigbee sensor driver and so on. But there’s a problem: the number of profiles per driver is limited. And even worse, the package size is limited. The official SmartThings developers only recently realized this - just as I did when I wanted to add my own Matter camera sub-driver.