diff --git a/.github/actions/configure-aws-profile/action.yml b/.github/actions/configure-aws-profile/action.yml deleted file mode 100644 index 751297a72..000000000 --- a/.github/actions/configure-aws-profile/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Configure AWS profile - -description: Modifies the AWS credentials file for the supplied profile - -inputs: - profile: - description: Name of the profile - required: true - aws-region: - description: AWS Region, e.g. us-east-2 - required: true - aws-access-key-id: - description: AWS Access Key ID - required: true - aws-secret-access-key: - description: AWS Secret Access Key - required: true - aws-session-token: - description: AWS Session Token. - required: true - -runs: - using: 'node24' - main: 'index.js' - post: 'cleanup.js' diff --git a/.github/actions/configure-aws-profile/cleanup.js b/.github/actions/configure-aws-profile/cleanup.js deleted file mode 100644 index 27826313d..000000000 --- a/.github/actions/configure-aws-profile/cleanup.js +++ /dev/null @@ -1,40 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -function deleteFolderRecursive(folderPath) { - try { - if (fs.existsSync(folderPath)) { - fs.readdirSync(folderPath).forEach((file) => { - const curPath = path.join(folderPath, file); - try { - if (fs.lstatSync(curPath).isDirectory()) { - deleteFolderRecursive(curPath); - } else { - fs.unlinkSync(curPath); - } - } catch (err) { - console.warn(`Warning: Failed to delete ${curPath}: ${err.message}`); - } - }); - fs.rmdirSync(folderPath); - } - } catch (err) { - console.warn(`Warning: Error processing ${folderPath}: ${err.message}`); - } -} - -try { - const homeDir = os.homedir(); - const awsFolder = path.join(homeDir, '.aws'); - - console.log(`Cleaning up AWS credentials folder: ${awsFolder}`); - - deleteFolderRecursive(awsFolder); - - console.log('AWS credentials folder cleanup completed successfully'); -} catch (error) { - console.error(`Error during cleanup: ${error.message}`); - // TODO don't exit with error for cleanup issues - process.exit(1); -} diff --git a/.github/actions/configure-aws-profile/index.js b/.github/actions/configure-aws-profile/index.js deleted file mode 100644 index 4cbe80ccb..000000000 --- a/.github/actions/configure-aws-profile/index.js +++ /dev/null @@ -1,48 +0,0 @@ -const { execSync } = require('child_process'); - -try { - const profile = process.env['INPUT_PROFILE']; - const region = process.env['INPUT_AWS-REGION']; - const accessKeyId = process.env['INPUT_AWS-ACCESS-KEY-ID']; - const secretAccessKey = process.env['INPUT_AWS-SECRET-ACCESS-KEY']; - const sessionToken = process.env['INPUT_AWS-SESSION-TOKEN']; - - const missingInputs = []; - if (!profile) { - missingInputs.push('profile'); - } - if (!region) { - missingInputs.push('region'); - } - if (!accessKeyId) { - missingInputs.push('accessKeyId'); - } - if (!secretAccessKey) { - missingInputs.push('secretAccessKey'); - } - if (!sessionToken) { - missingInputs.push('sessionToken'); - } - if (missingInputs.length > 0) { - throw new Error(`Missing required input(s): ${missingInputs}`); - } - - console.log(`Configuring AWS Profile ${profile}`); - - const commands = [ - `aws configure set --profile ${profile} region ${region}`, - `aws configure set --profile ${profile} aws_access_key_id ${accessKeyId}`, - `aws configure set --profile ${profile} aws_secret_access_key ${secretAccessKey}`, - `aws configure set --profile ${profile} aws_session_token ${sessionToken}`, - ]; - - // Execute each command synchronously - for (const command of commands) { - execSync(command); - } - - console.log('AWS profile configuration completed successfully'); -} catch (error) { - console.error(error.message); - process.exit(1); -} diff --git a/.github/actions/configure-multiple-aws-roles/action.yml b/.github/actions/configure-multiple-aws-roles/action.yml deleted file mode 100644 index 7b2f43a1b..000000000 --- a/.github/actions/configure-multiple-aws-roles/action.yml +++ /dev/null @@ -1,158 +0,0 @@ -# Copied from https://github.com/Moulick/configure-multiple-aws-roles - -name: Configure Multiple AWS Roles - -description: Drop-in replacement for aws-actions/configure-aws-credentials with additional features to configure multiple AWS roles. - -inputs: - # Additional inputs vs aws-actions/configure-aws-credentials@v4 - profile: - required: false - description: Name of profile to be created - default: "default" - - # Copied from aws-actions/configure-aws-credentials@v4 - aws-region: - description: AWS Region, e.g. us-east-2 - required: true - role-to-assume: - description: The Amazon Resource Name (ARN) of the role to assume. Use the provided credentials to assume an IAM role and configure the Actions environment with the assumed role credentials rather than with the provided credentials. - required: false - aws-access-key-id: - description: AWS Access Key ID. Provide this key if you want to assume a role using access keys rather than a web identity token. - required: false - aws-secret-access-key: - description: AWS Secret Access Key. Required if aws-access-key-id is provided. - required: false - aws-session-token: - description: AWS Session Token. - required: false - web-identity-token-file: - description: Use the web identity token file from the provided file system path in order to assume an IAM role using a web identity, e.g. from within an Amazon EKS worker node. - required: false - role-chaining: - description: Use existing credentials from the environment to assume a new role, rather than providing credentials as inputs. - required: false - audience: - description: The audience to use for the OIDC provider - required: false - default: sts.amazonaws.com - http-proxy: - description: Proxy to use for the AWS SDK agent - required: false - mask-aws-account-id: - description: Whether to mask the AWS account ID for these credentials as a secret value. By default the account ID will not be masked - required: false - role-duration-seconds: - description: Role duration in seconds. - default: 7200 - required: false - role-external-id: - description: The external ID of the role to assume. - required: false - role-session-name: - description: "Role session name (default: GitHubActions)" - required: false - role-skip-session-tagging: - description: Skip session tagging during role assumption - required: false - inline-session-policy: - description: Define an inline session policy to use when assuming a role - required: false - managed-session-policies: - description: Define a list of managed session policies to use when assuming a role - required: false - unset-current-credentials: - description: Whether to unset the existing credentials in your runner. May be useful if you run this action multiple times in the same job - required: false - default: "true" # Setting to true by default as recommended by the official action if called multiple times in the same job - disable-retry: - description: Whether to disable the retry and backoff mechanism when the assume role call fails. By default the retry mechanism is enabled - required: false - retry-max-attempts: - description: The maximum number of attempts it will attempt to retry the assume role call. By default it will retry 12 times - required: false - special-characters-workaround: - description: Some environments do not support special characters in AWS_SECRET_ACCESS_KEY. This option will retry fetching credentials until the secret access key does not contain special characters. This option overrides disable-retry and retry-max-attempts. This option is disabled by default - required: false - -outputs: - aws-account-id: - description: The AWS account ID for the provided credentials - value: ${{ steps.aws-credentials.outputs.aws-account-id }} - aws-access-key-id: - description: The AWS access key ID for the provided credentials - value: ${{ steps.aws-credentials.outputs.aws-access-key-id }} - aws-secret-access-key: - description: The AWS secret access key for the provided credentials - value: ${{ steps.aws-credentials.outputs.aws-secret-access-key }} - aws-session-token: - description: The AWS session token for the provided credentials - value: ${{ steps.aws-credentials.outputs.aws-session-token }} - # Adding this output just in case someone needs it, it's the same as the input anyways - profile: - description: The name of the profile that was configured - value: ${{ steps.save-to-profile.outputs.profile }} - -runs: - using: composite - steps: - - name: Get AWS Credentials - id: aws-credentials - uses: aws-actions/configure-aws-credentials@v6 - with: - output-credentials: true - - aws-region: ${{ inputs.aws-region }} - role-to-assume: ${{ inputs.role-to-assume }} - aws-access-key-id: ${{ inputs.aws-access-key-id }} - aws-secret-access-key: ${{ inputs.aws-secret-access-key }} - aws-session-token: ${{ inputs.aws-session-token }} - web-identity-token-file: ${{ inputs.web-identity-token-file }} - role-chaining: ${{ inputs.role-chaining }} - audience: ${{ inputs.audience }} - http-proxy: ${{ inputs.http-proxy }} - mask-aws-account-id: ${{ inputs.mask-aws-account-id }} - role-duration-seconds: ${{ inputs.role-duration-seconds }} - role-external-id: ${{ inputs.role-external-id }} - role-session-name: ${{ inputs.role-session-name }} - role-skip-session-tagging: ${{ inputs.role-skip-session-tagging }} - inline-session-policy: ${{ inputs.inline-session-policy }} - managed-session-policies: ${{ inputs.managed-session-policies }} - unset-current-credentials: ${{ inputs.unset-current-credentials }} - disable-retry: ${{ inputs.disable-retry }} - retry-max-attempts: ${{ inputs.retry-max-attempts }} - special-characters-workaround: ${{ inputs.special-characters-workaround }} - - - uses: "./.github/actions/configure-aws-profile" - with: - profile: ${{ inputs.profile }} - aws-region: ${{ inputs.aws-region }} - aws-access-key-id: ${{ steps.aws-credentials.outputs.aws-access-key-id }} - aws-secret-access-key: ${{ steps.aws-credentials.outputs.aws-secret-access-key }} - aws-session-token: ${{ steps.aws-credentials.outputs.aws-session-token }} - - # configure-aws-profile above sets the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and - # AWS_SESSION_TOKEN environment variables. If these environment variables are set, - # they take precedence over *any* other AWS credential configuration, such as the credentials - # file or the AWS_PROFILE environment variable. - # This means that if configure-multiple-aws-roles is called multiple times, the last role - # that it is called for will be the AWS credentials used by the AWS CLI and SDKs for the rest - # of the job. We call this action multiple times and we explicitly want to use roles based - # on the AWS_PROFILE environment variable, so we unset these three environment variables. - - - name: Set environment variables to blank values - if: runner.os != 'Windows' - shell: bash - run: | - echo 'AWS_ACCESS_KEY_ID=' >> $GITHUB_ENV - echo 'AWS_SECRET_ACCESS_KEY=' >> $GITHUB_ENV - echo 'AWS_SESSION_TOKEN=' >> $GITHUB_ENV - - - name: Set environment variables to blank values - if: runner.os == 'Windows' - shell: powershell - run: | - "AWS_ACCESS_KEY_ID=" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "AWS_SECRET_ACCESS_KEY=" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "AWS_SESSION_TOKEN=" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/release_s3_manifest.py b/.github/scripts/release_s3_manifest.py deleted file mode 100644 index 2b1843f1a..000000000 --- a/.github/scripts/release_s3_manifest.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -"""Generate S3 release-manifest JSON files for the geniex public bucket. - -Three artifacts, all served anonymously from -``s3://qaihub-public-assets/qai-hub-geniex/``: - -* ``manifest-.json`` — per-tag asset listing (immutable). -* ``index.json`` — full version catalogue (mutable, no-cache). -* ``latest.json`` — pointer to the latest stable tag (mutable, no-cache; - only refreshed on bare ``vX.Y.Z`` tags). - -The script only writes local files; the calling workflow uploads them with -``aws s3 cp`` so credentials, ACL flags, and cache headers stay in one place. -``index.json`` is fetched from S3 with ``aws s3api`` if it already exists. - -Inputs come from environment variables to keep the workflow surface tight: - -* ``RELEASE_TAG`` — e.g. ``v0.1.6-rc.1``. -* ``IS_PRERELEASE`` — ``true`` / ``false`` (matches the resolve-tag job). -* ``HTP_SIGNED`` — ``true`` / ``false`` from overlay-htp. -* ``LLAMA_SHA`` — short SHA from overlay-htp. -* ``RELEASE_ASSETS_DIR`` — directory containing the downloaded release-assets - artifact (zips, exe, apk, sha256 sidecars). -* ``OUTPUT_DIR`` — where to write the generated JSON files. -* ``S3_BUCKET`` — default ``qaihub-public-assets``. -* ``S3_PREFIX`` — default ``qai-hub-geniex/`` (trailing slash kept). -* ``PUBLIC_BASE_URL`` — default - ``https://qaihub-public-assets.s3.us-west-2.amazonaws.com``. -""" - -from __future__ import annotations - -import argparse -import datetime as dt -import json -import os -import re -import subprocess -import sys -from pathlib import Path - -SCHEMA_VERSION = 1 - -# Files that the workflow uploads to S3. Anything not in here is GitHub-Release-only. -ASSET_PATTERNS: list[tuple[re.Pattern[str], dict[str, str]]] = [ - (re.compile(r"^geniex-sdk-windows-arm64-.+\.zip$"), - {"kind": "sdk", "platform": "windows", "arch": "arm64"}), - (re.compile(r"^geniex-sdk-linux-arm64-.+\.zip$"), - {"kind": "sdk", "platform": "linux", "arch": "arm64"}), - (re.compile(r"^geniex-cli-setup-windows-arm64-.+\.exe$"), - {"kind": "cli-installer", "platform": "windows", "arch": "arm64"}), - (re.compile(r"^geniex-cli-linux-arm64-.+\.tar\.gz$"), - {"kind": "cli-archive", "platform": "linux", "arch": "arm64"}), - (re.compile(r"^install-.+\.sh$"), - {"kind": "install-script", "platform": "linux", "arch": "arm64"}), - (re.compile(r"^geniex-demo-.+\.apk$"), - {"kind": "android-demo", "platform": "android", "arch": "arm64"}), -] - - -def now_utc_iso() -> str: - return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def read_sidecar_sha256(sidecar: Path) -> str: - # `sha256sum` writes " ". - return sidecar.read_text().split()[0] - - -def classify(name: str) -> dict[str, str] | None: - if name.endswith(".sha256"): - base = name[: -len(".sha256")] - meta = classify(base) - if meta is None: - return None - return {**meta, "kind": "sha256"} - for pat, meta in ASSET_PATTERNS: - if pat.match(name): - return dict(meta) - return None - - -def build_tag_manifest(args: argparse.Namespace) -> None: - tag = os.environ["RELEASE_TAG"] - assets_dir = Path(os.environ["RELEASE_ASSETS_DIR"]) - output_dir = Path(os.environ["OUTPUT_DIR"]) - output_dir.mkdir(parents=True, exist_ok=True) - base_url = os.environ.get( - "PUBLIC_BASE_URL", - "https://qaihub-public-assets.s3.us-west-2.amazonaws.com", - ).rstrip("/") - prefix = os.environ.get("S3_PREFIX", "qai-hub-geniex/").strip("/") - bucket = os.environ.get("S3_BUCKET", "qaihub-public-assets") - - is_prerelease = os.environ["IS_PRERELEASE"].lower() == "true" - htp_signed = os.environ.get("HTP_SIGNED", "").lower() == "true" - llama_sha = os.environ.get("LLAMA_SHA", "") - - # Per-tag manifest is immutable. On a workflow re-run, preserve the original - # released_at so the file stays byte-identical. - prior = fetch_existing_object(bucket, f"{prefix}/manifest-{tag}.json") - released_at = prior["released_at"] if prior else now_utc_iso() - - assets: list[dict[str, object]] = [] - for entry in sorted(assets_dir.iterdir()): - if not entry.is_file(): - continue - meta = classify(entry.name) - if meta is None: - continue - sha_path = entry.with_name(entry.name + ".sha256") - if entry.name.endswith(".sha256"): - # Sidecar's own sha256 is the one inside its content's sidecar — i.e. itself. - # Recompute on the fly to keep the manifest self-contained. - import hashlib - sha = hashlib.sha256(entry.read_bytes()).hexdigest() - else: - if not sha_path.exists(): - raise SystemExit(f"missing sha256 sidecar for {entry.name}") - sha = read_sidecar_sha256(sha_path) - assets.append({ - "name": entry.name, - "url": f"{base_url}/{prefix}/{entry.name}", - "size": entry.stat().st_size, - "sha256": sha, - **meta, - }) - - manifest = { - "schema_version": SCHEMA_VERSION, - "tag": tag, - "is_prerelease": is_prerelease, - "released_at": released_at, - "llama_sha": llama_sha, - "htp_signed": htp_signed, - "assets": assets, - } - out = output_dir / f"manifest-{tag}.json" - out.write_text(json.dumps(manifest, indent=2) + "\n") - print(f"wrote {out} ({len(assets)} assets)") - - -# SemVer 2.0 ordering, restricted to the v-prefix used by this repo. -_SEMVER_RE = re.compile( - r"^v(?P\d+)\.(?P\d+)\.(?P\d+)" - r"(?:-(?P
[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$",
-)
-
-
-def semver_key(tag: str) -> tuple:
-    """Sort key for SemVer tags. Pre-release sorts lower than its release."""
-    m = _SEMVER_RE.match(tag)
-    if not m:
-        return (0, 0, 0, (1,), tag)
-    base = (int(m["major"]), int(m["minor"]), int(m["patch"]))
-    pre = m["pre"]
-    if pre is None:
-        # Release > any prerelease for the same base.
-        return (*base, (1,), "")
-    parts: list = []
-    for ident in pre.split("."):
-        parts.append((0, int(ident)) if ident.isdigit() else (1, ident))
-    return (*base, (0, *parts), pre)
-
-
-def fetch_existing_object(bucket: str, key: str) -> dict | None:
-    """GET a JSON object from S3 via aws s3api, None if it doesn't exist."""
-    import tempfile
-
-    with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp:
-        tmp_path = tmp.name
-    try:
-        proc = subprocess.run(
-            ["aws", "s3api", "get-object", "--bucket", bucket, "--key", key, tmp_path],
-            capture_output=True,
-        )
-        if proc.returncode != 0:
-            stderr = proc.stderr.decode("utf-8", errors="replace")
-            if "NoSuchKey" in stderr or "404" in stderr or "Not Found" in stderr:
-                return None
-            raise SystemExit(f"aws s3api get-object failed: {stderr}")
-        return json.loads(Path(tmp_path).read_text())
-    finally:
-        try:
-            os.unlink(tmp_path)
-        except OSError:
-            pass
-
-
-def fetch_existing_index(bucket: str, prefix: str) -> dict | None:
-    return fetch_existing_object(bucket, f"{prefix.strip('/')}/index.json")
-
-
-def update_index(args: argparse.Namespace) -> None:
-    tag = os.environ["RELEASE_TAG"]
-    is_prerelease = os.environ["IS_PRERELEASE"].lower() == "true"
-    output_dir = Path(os.environ["OUTPUT_DIR"])
-    bucket = os.environ.get("S3_BUCKET", "qaihub-public-assets")
-    prefix = os.environ.get("S3_PREFIX", "qai-hub-geniex/")
-
-    existing = fetch_existing_index(bucket, prefix) or {
-        "schema_version": SCHEMA_VERSION,
-        "versions": [],
-    }
-    prior = next(
-        (v for v in existing.get("versions", []) if v.get("tag") == tag), None,
-    )
-    versions = [v for v in existing.get("versions", []) if v.get("tag") != tag]
-    versions.append({
-        "tag": tag,
-        "is_prerelease": is_prerelease,
-        # Preserve the original release timestamp on workflow re-runs of the
-        # same tag — only set it on the first publish.
-        "released_at": prior["released_at"] if prior else now_utc_iso(),
-        "manifest": f"manifest-{tag}.json",
-    })
-    versions.sort(key=lambda v: semver_key(v["tag"]), reverse=True)
-
-    latest_stable = next(
-        (v["tag"] for v in versions if not v.get("is_prerelease")), None,
-    )
-    latest_prerelease = next(
-        (v["tag"] for v in versions if v.get("is_prerelease")), None,
-    )
-
-    index = {
-        "schema_version": SCHEMA_VERSION,
-        "updated_at": now_utc_iso(),
-        "latest_stable": latest_stable,
-        "latest_prerelease": latest_prerelease,
-        "versions": versions,
-    }
-    out = output_dir / "index.json"
-    out.write_text(json.dumps(index, indent=2) + "\n")
-    print(f"wrote {out} ({len(versions)} versions)")
-
-
-def build_latest(args: argparse.Namespace) -> None:
-    tag = os.environ["RELEASE_TAG"]
-    if os.environ["IS_PRERELEASE"].lower() == "true":
-        print("prerelease — skipping latest.json")
-        return
-    output_dir = Path(os.environ["OUTPUT_DIR"])
-    # Carry the released_at from the just-built per-tag manifest so latest.json
-    # stays consistent with it across re-runs.
-    manifest_path = output_dir / f"manifest-{tag}.json"
-    released_at = json.loads(manifest_path.read_text())["released_at"]
-    out = output_dir / "latest.json"
-    out.write_text(json.dumps({
-        "schema_version": SCHEMA_VERSION,
-        "tag": tag,
-        "released_at": released_at,
-        "manifest": f"manifest-{tag}.json",
-    }, indent=2) + "\n")
-    print(f"wrote {out}")
-
-
-def main() -> int:
-    parser = argparse.ArgumentParser(description=__doc__)
-    sub = parser.add_subparsers(dest="cmd", required=True)
-    sub.add_parser("build-tag-manifest").set_defaults(fn=build_tag_manifest)
-    sub.add_parser("update-index").set_defaults(fn=update_index)
-    sub.add_parser("build-latest").set_defaults(fn=build_latest)
-    args = parser.parse_args()
-    args.fn(args)
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/.github/workflows/_build-cli.yml b/.github/workflows/_build-cli.yml
index 8d37a8183..32c2ce882 100644
--- a/.github/workflows/_build-cli.yml
+++ b/.github/workflows/_build-cli.yml
@@ -49,7 +49,7 @@ jobs:
         with:
           fetch-depth: 1
           submodules: false
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Detect build version
         id: version
diff --git a/.github/workflows/_build-docker.yml b/.github/workflows/_build-docker.yml
index 840a80b31..dd4edfead 100644
--- a/.github/workflows/_build-docker.yml
+++ b/.github/workflows/_build-docker.yml
@@ -42,7 +42,7 @@ jobs:
         with:
           fetch-depth: 1
           submodules: false
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Detect build version
         id: version
diff --git a/.github/workflows/_build-sdk.yml b/.github/workflows/_build-sdk.yml
index cf16e38d2..95133a82d 100644
--- a/.github/workflows/_build-sdk.yml
+++ b/.github/workflows/_build-sdk.yml
@@ -71,7 +71,7 @@ jobs:
         with:
           fetch-depth: 1
           submodules: recursive
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Detect build version
         id: version
diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml
index aa42dbb92..5227c951d 100644
--- a/.github/workflows/_lint.yml
+++ b/.github/workflows/_lint.yml
@@ -17,7 +17,7 @@ jobs:
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Resolve C/C++ file list
         id: files
@@ -53,7 +53,7 @@ jobs:
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Setup Python 3.10
         uses: actions/setup-python@v6
@@ -96,7 +96,7 @@ jobs:
       - name: Checkout source
         uses: actions/checkout@v6
         with:
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Setup Go
         uses: actions/setup-go@v6
@@ -120,7 +120,7 @@ jobs:
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Resolve Rust file list
         id: files
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index dee30cda2..1e11c02ab 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -78,7 +78,7 @@ jobs:
           # avoids "missing base" failures on multi-commit PRs.
           fetch-depth: 0
           submodules: false
-          token: ${{ secrets.GH_PAT }}
+          token: ${{ secrets.CLONE_PAT }}
 
       - name: Resolve changed files
         id: files
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5969823c2..f5d409c42 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -374,193 +374,50 @@ jobs:
             const release = require('./.github/scripts/release.js');
             await release({ github, context, core });
 
+  # S3 publish runs in the geniex repo, not here: the bitsyfactory IAM role's
+  # OIDC trust only allows repo:qcom-ai-hub/geniex, so nexa-sdk cannot assume it.
+  # We dispatch geniex's chore/publish-s3 workflow (GH_PAT, with actions:rw on
+  # geniex), passing this run's id so it can pull our release-assets artifact
+  # cross-repo, then watch it so an S3 failure surfaces here instead of silently.
   publish-s3:
-    name: publish-s3
+    name: publish-s3 (via geniex)
     needs: [resolve-tag, overlay-htp, package-release]
     runs-on: ubuntu-latest
-    timeout-minutes: 10
-    permissions:
-      contents: read
-      id-token: write
-    env:
-      RELEASE_TAG: ${{ needs.resolve-tag.outputs.tag }}
-      IS_PRERELEASE: ${{ needs.resolve-tag.outputs.is_prerelease }}
-      HTP_SIGNED: ${{ needs.overlay-htp.outputs.signed }}
-      LLAMA_SHA: ${{ needs.overlay-htp.outputs.llama_sha }}
-      S3_BUCKET: qaihub-public-assets
-      S3_PREFIX: qai-hub-geniex/
-      PUBLIC_BASE_URL: https://qaihub-public-assets.s3.us-west-2.amazonaws.com
+    timeout-minutes: 20
     steps:
-      - uses: actions/checkout@v6
-        with:
-          fetch-depth: 1
-          submodules: false
-
-      # Pin so the manifest scripts below don't rely on the runner image's `python` alias.
-      - uses: actions/setup-python@v6
-        with:
-          python-version: "3.11"
-
-      - uses: actions/download-artifact@v8
-        with:
-          name: release-assets
-          path: release-assets
-
-      - name: Configure AWS credentials (bitsyfactory role)
-        uses: ./.github/actions/configure-multiple-aws-roles
-        with:
-          profile: bitsy
-          aws-region: us-west-2
-          role-to-assume: arn:aws:iam::234967179373:role/github-bitsyfactory
-          role-session-name: geniex-release-${{ github.run_id }}
-
-      - name: Upload versioned release assets to S3 (flat)
-        shell: bash
-        env:
-          AWS_PROFILE: bitsy
-          AWS_DEFAULT_REGION: us-west-2
-        run: |
-          set -euo pipefail
-          # Flat layout under qai-hub-geniex/ — the  in every filename
-          # disambiguates versions. SDK zips, CLI installer (+ .sha256), and
-          # APK (+ .sha256) all land at the prefix root. --acl public-read is
-          # required because the bucket policy only opens qai-hub-models/wheels
-          # for anonymous GetObject by default.
-          aws s3 sync release-assets/ "s3://${S3_BUCKET}/${S3_PREFIX}" \
-            --exclude '*' \
-            --include 'geniex-sdk-windows-arm64-*.zip' \
-            --include 'geniex-sdk-windows-arm64-*.zip.sha256' \
-            --include 'geniex-sdk-linux-arm64-*.zip' \
-            --include 'geniex-sdk-linux-arm64-*.zip.sha256' \
-            --include 'geniex-bench-linux-arm64-*.tar.gz' \
-            --include 'geniex-bench-linux-arm64-*.tar.gz.sha256' \
-            --include 'geniex-bench-android-arm64-*.tar.gz' \
-            --include 'geniex-bench-android-arm64-*.tar.gz.sha256' \
-            --include 'geniex-bench-windows-arm64-*.zip' \
-            --include 'geniex-bench-windows-arm64-*.zip.sha256' \
-            --include "geniex-cli-setup-windows-arm64-${RELEASE_TAG}.exe" \
-            --include "geniex-cli-setup-windows-arm64-${RELEASE_TAG}.exe.sha256" \
-            --include "geniex-cli-linux-arm64-${RELEASE_TAG}.tar.gz" \
-            --include "geniex-cli-linux-arm64-${RELEASE_TAG}.tar.gz.sha256" \
-            --include "install-${RELEASE_TAG}.sh" \
-            --include "install-${RELEASE_TAG}.sh.sha256" \
-            --include "geniex-demo-${RELEASE_TAG}.apk" \
-            --include "geniex-demo-${RELEASE_TAG}.apk.sha256" \
-            --acl public-read
-
-      - name: Force text/x-shellscript content-type on the install scripts
-        shell: bash
-        env:
-          AWS_PROFILE: bitsy
-          AWS_DEFAULT_REGION: us-west-2
-        run: |
-          set -euo pipefail
-          # Re-upload the .sh objects so curl-piping browsers don't get a
-          # text/html or application/octet-stream that some shells refuse.
-          aws s3 cp \
-            "release-assets/install-${RELEASE_TAG}.sh" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}install-${RELEASE_TAG}.sh" \
-            --content-type "text/x-shellscript; charset=utf-8" \
-            --acl public-read
-
-      - name: Refresh mutable "latest stable" pointers
-        if: ${{ needs.resolve-tag.outputs.is_prerelease != 'true' }}
-        shell: bash
-        env:
-          AWS_PROFILE: bitsy
-          AWS_DEFAULT_REGION: us-west-2
-        run: |
-          set -euo pipefail
-          # Only advance the unversioned pointers on bare vX.Y.Z so rc/alpha
-          # builds never become the default download. Cache-Control no-cache
-          # forces revalidation since the bytes change each release.
-          aws s3 cp \
-            "release-assets/geniex-cli-setup-windows-arm64-${RELEASE_TAG}.exe" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-cli.exe" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          aws s3 cp \
-            "release-assets/geniex-cli-linux-arm64-${RELEASE_TAG}.tar.gz" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-cli-linux-arm64.tar.gz" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          aws s3 cp \
-            "release-assets/geniex-cli-linux-arm64-${RELEASE_TAG}.tar.gz.sha256" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-cli-linux-arm64.tar.gz.sha256" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          aws s3 cp \
-            "release-assets/install-${RELEASE_TAG}.sh" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}install.sh" \
-            --content-type "text/x-shellscript; charset=utf-8" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          aws s3 cp \
-            "release-assets/geniex-demo-${RELEASE_TAG}.apk" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-demo.apk" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          # geniex-bench latest pointers (one per platform).
-          aws s3 cp \
-            "release-assets/geniex-bench-linux-arm64-${RELEASE_TAG}.tar.gz" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-bench-linux-arm64.tar.gz" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          aws s3 cp \
-            "release-assets/geniex-bench-android-arm64-${RELEASE_TAG}.tar.gz" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-bench-android-arm64.tar.gz" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          aws s3 cp \
-            "release-assets/geniex-bench-windows-arm64-${RELEASE_TAG}.zip" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}geniex-bench-windows-arm64.zip" \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-
-      - name: Generate manifest files
-        shell: bash
+      - name: Trigger & watch geniex publish-s3
         env:
-          AWS_PROFILE: bitsy
-          AWS_DEFAULT_REGION: us-west-2
-          RELEASE_ASSETS_DIR: release-assets
-          OUTPUT_DIR: manifests
+          GH_TOKEN: ${{ secrets.GH_PAT }}
+          TAG: ${{ needs.resolve-tag.outputs.tag }}
+          IS_PRERELEASE: ${{ needs.resolve-tag.outputs.is_prerelease }}
+          HTP_SIGNED: ${{ needs.overlay-htp.outputs.signed }}
+          LLAMA_SHA: ${{ needs.overlay-htp.outputs.llama_sha }}
+          RUN_ID: ${{ github.run_id }}
         run: |
           set -euo pipefail
-          python .github/scripts/release_s3_manifest.py build-tag-manifest
-          python .github/scripts/release_s3_manifest.py update-index
-          python .github/scripts/release_s3_manifest.py build-latest
+          gh workflow run release.yml \
+            --repo qcom-ai-hub/geniex \
+            --ref chore/publish-s3 \
+            -f tag="${TAG}" \
+            -f is_prerelease="${IS_PRERELEASE}" \
+            -f run_id="${RUN_ID}" \
+            -f htp_signed="${HTP_SIGNED}" \
+            -f llama_sha="${LLAMA_SHA}"
+
+          # The geniex run-name embeds "run ${RUN_ID}", so match on it to find
+          # the run we just dispatched rather than racing on most-recent.
+          target=""
+          for _ in $(seq 1 12); do
+            target=$(gh run list --repo qcom-ai-hub/geniex --workflow release.yml \
+              --branch chore/publish-s3 --json databaseId,displayTitle \
+              --jq "[.[] | select(.displayTitle | contains(\"run ${RUN_ID}\"))][0].databaseId")
+            [ -n "${target}" ] && break
+            sleep 5
+          done
+          [ -n "${target}" ] || { echo "::error::geniex publish-s3 run for nexa-sdk run ${RUN_ID} not found"; exit 1; }
 
-      - name: Upload manifest files to S3
-        shell: bash
-        env:
-          AWS_PROFILE: bitsy
-          AWS_DEFAULT_REGION: us-west-2
-        run: |
-          set -euo pipefail
-          # Per-tag manifest is immutable: the tag itself never changes (it's a
-          # project hard constraint), and the script preserves released_at on
-          # re-runs, so byte-identical contents can be cached forever.
-          aws s3 cp "manifests/manifest-${RELEASE_TAG}.json" \
-            "s3://${S3_BUCKET}/${S3_PREFIX}manifest-${RELEASE_TAG}.json" \
-            --content-type application/json \
-            --acl public-read \
-            --cache-control "public, max-age=31536000, immutable"
-          # index.json is mutable — every release adds a row. no-cache so apps
-          # see new versions immediately.
-          aws s3 cp manifests/index.json \
-            "s3://${S3_BUCKET}/${S3_PREFIX}index.json" \
-            --content-type application/json \
-            --acl public-read \
-            --cache-control "no-cache, max-age=0"
-          # latest.json only on stable tags — the build-latest subcommand is a
-          # no-op for prereleases and won't produce the file.
-          if [[ -f manifests/latest.json ]]; then
-            aws s3 cp manifests/latest.json \
-              "s3://${S3_BUCKET}/${S3_PREFIX}latest.json" \
-              --content-type application/json \
-              --acl public-read \
-              --cache-control "no-cache, max-age=0"
-          fi
+          echo "Watching geniex publish-s3 run ${target}"
+          gh run watch "${target}" --repo qcom-ai-hub/geniex --exit-status
 
   publish-docker:
     name: publish-docker
diff --git a/notes/release.md b/notes/release.md
index 304452cd2..ad48ee594 100644
--- a/notes/release.md
+++ b/notes/release.md
@@ -105,7 +105,7 @@ Other assets (AAR, sdist, HTP cert/to-sign zips) ship via GitHub Releases / Mave
 
 ### Manifest contract for client apps
 
-Schema lives in [.github/scripts/release_s3_manifest.py](../.github/scripts/release_s3_manifest.py); all files carry `schema_version: 1`.
+S3 publishing runs in the geniex repo (the IAM role's OIDC trust only allows `qcom-ai-hub/geniex`); this repo's `publish-s3` job just dispatches it. The schema lives in [`release_s3_manifest.py`](https://github.com/qcom-ai-hub/geniex/blob/chore/publish-s3/.github/scripts/release_s3_manifest.py) on geniex's `chore/publish-s3` branch; all files carry `schema_version: 1`.
 
 - **Update check (lightest)** — GET `latest.json`. Compare `tag` with the locally installed version. Cached `no-cache`, so the response is always current. Only present once a stable tag has shipped.