Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions .github/workflows/pinable-runtime.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
name: Pinable desktop runtime

on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- '.github/workflows/pinable-runtime.yml'
- 'scripts/pinable/runtime.py'
- 'scripts/pinable/test_runtime.py'
- 'scripts/pinable/test_publish_runtime.py'
- 'scripts/pinable/smoke_runtime.py'
- 'scripts/pinable/publish_runtime.py'
- 'docs/pinable/desktop-runtime.md'
- 'internal/watcher/**'
workflow_dispatch:
inputs:
version:
description: 'Optional version label; default is source timestamp + SHA'
type: string
required: false
publish_release:
description: 'Publish verified assets as a dedicated GitHub prerelease (main only)'
type: boolean
default: false

permissions:
contents: read

concurrency:
group: pinable-runtime-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
PYTHONDONTWRITEBYTECODE: '1'
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
RUNTIME_VERSION: ${{ inputs.version || '' }}

jobs:
quality:
if: github.repository == 'PinableAgents/CLIProxyAPI'
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_SHA }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Distribution and synchronization tool regressions
run: |
python -m unittest discover -s scripts/pinable -p 'test_*runtime.py' -v
python scripts/pinable/test_sync_upstream.py -v
bash -n scripts/pinable/sync-upstream.sh
- name: Full application suite (normal CGO profile)
run: go test ./...
- name: Repeat bootstrap ordering regressions
run: go test ./internal/runtime/executor -run 'TestCodex(Websockets)?Executor_BootstrapBuffering_(TimeBudgetReleasesStream|OverloadDirectlyAfterTimeoutDeliveredInStream|StatusBearingErrorAfterTimeoutDeliveredInStream)$' -count=20

package:
if: github.repository == 'PinableAgents/CLIProxyAPI'
name: Package ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- target: windows/amd64
id: windows-amd64
runner: windows-2025
- target: windows/arm64
id: windows-arm64
runner: windows-11-arm
- target: darwin/amd64
id: darwin-amd64
runner: macos-15-intel
- target: darwin/arm64
id: darwin-arm64
runner: macos-15
- target: linux/amd64
id: linux-amd64
runner: ubuntu-22.04
- target: linux/arm64
id: linux-arm64
runner: ubuntu-22.04-arm
env:
CGO_ENABLED: '0'
TARGET: ${{ matrix.target }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_SHA }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Test host integration on the native architecture
shell: bash
run: |
python -m unittest discover -s scripts/pinable -p 'test_*runtime.py' -v
go test ./cmd/server ./internal/cmd ./internal/api ./internal/api/handlers/management ./sdk/access ./sdk/cliproxy ./internal/watcher -run 'Test.*(Host|Runtime|Ephemeral|Parent|Version|SafeMode)' -count=1
- name: Build pinned portable runtime (no catalog refresh)
env:
ARTIFACT_DIR: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }}
shell: bash
run: python scripts/pinable/runtime.py build --target "$TARGET" --version "$RUNTIME_VERSION" --output "$ARTIFACT_DIR"
- name: Exercise the actual packaged executable
env:
ARTIFACT_DIR: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }}
shell: bash
run: python scripts/pinable/smoke_runtime.py --artifact-dir "$ARTIFACT_DIR"
- uses: actions/upload-artifact@v4
id: upload
with:
name: pinable-runtime-${{ matrix.id }}
path: ${{ runner.temp }}/pinable-runtime-${{ matrix.id }}/*
if-no-files-found: error
compression-level: 0
retention-days: 30
- name: Record platform download
shell: bash
env:
DOWNLOAD_URL: ${{ steps.upload.outputs.artifact-url }}
run: printf '%s\n' "### $TARGET" "[Download native-verified runtime]($DOWNLOAD_URL)" >> "$GITHUB_STEP_SUMMARY"

assemble:
name: Verify complete runtime set
needs: [quality, package]
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_SHA }}
persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: pinable-runtime-*
path: ${{ runner.temp }}/runtime-input
- name: Verify every archive and assemble index/checksums
env:
INPUT_DIR: ${{ runner.temp }}/runtime-input
OUTPUT_DIR: ${{ runner.temp }}/runtime-downloads
run: |
python scripts/pinable/runtime.py index --input "$INPUT_DIR" --output "$OUTPUT_DIR" --expected-commit "$SOURCE_SHA"
python - <<'PY'
import json, os, pathlib
root = pathlib.Path(os.environ['INPUT_DIR'])
reports = [json.loads(p.read_text()) for p in root.rglob('smoke-report.json')]
expected = {f'{s}/{a}' for s in ['windows','darwin','linux'] for a in ['amd64','arm64']}
assert len(reports) == 6 and {r['target'] for r in reports} == expected
assert all(r['status'] == 'passed' and r['source_commit'] == os.environ['SOURCE_SHA'] for r in reports)
output = pathlib.Path(os.environ['OUTPUT_DIR'])
index = json.loads((output / 'runtime-index.json').read_text())
hashes = {m['target']: m['binary']['sha256'] for m in index['targets']}
assert all(r['binary_sha256'] == hashes[r['target']] for r in reports)
(output / 'validation.json').write_text(json.dumps({'source_commit':os.environ['SOURCE_SHA'],'quality':'passed','native_smoke':reports}, indent=2)+'\n')
import hashlib
with (output / 'SHA256SUMS').open('a') as sums:
sums.write(hashlib.sha256((output / 'validation.json').read_bytes()).hexdigest() + ' validation.json\n')
PY
- uses: actions/upload-artifact@v4
id: upload
with:
name: pinable-runtime-all
path: ${{ runner.temp }}/runtime-downloads/*
if-no-files-found: error
compression-level: 0
retention-days: 90
- name: Record the complete download
env:
DOWNLOAD_URL: ${{ steps.upload.outputs.artifact-url }}
run: |
printf '%s\n' '## Pinable Desktop runtime set' "Source: \`$SOURCE_SHA\`" "[Download all six verified platforms, index and SHA256SUMS]($DOWNLOAD_URL)" 'No Desktop component lock has been modified.' >> "$GITHUB_STEP_SUMMARY"

publish:
name: Publish explicitly requested runtime prerelease
if: github.repository == 'PinableAgents/CLIProxyAPI' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.publish_release
needs: [assemble]
runs-on: ubuntu-22.04
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_SHA }}
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: pinable-runtime-all
path: ${{ runner.temp }}/runtime-release
- name: Publish immutable assets without replacing the upstream latest release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_DIR: ${{ runner.temp }}/runtime-release
run: python scripts/pinable/publish_runtime.py --directory "$RELEASE_DIR" --expected-commit "$SOURCE_SHA"
187 changes: 187 additions & 0 deletions docs/pinable/desktop-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Pinable Desktop runtime distribution

## Scope and Desktop contract

`Pinable desktop runtime` builds CLIProxyAPI component runtimes, not Desktop
installers. The workflow and stdlib Python tools are fork-owned and separate from
upstream release workflows. Dependencies, model catalogs, release credentials and
Desktop's Go build orchestrator are not changed.

The supplied Desktop build/release document requires pinned source revisions and
SHA256 values, generated assets outside Git, native Windows/macOS/Linux packages,
and fail-fast component validation before Wails. It supports an absolute binary
path through `CLIPROXYAPI_COMPONENT_ASSET`.

That document does not define the Desktop `component-lock.json` schema or the
exact `CLIPROXYAPI_COMPONENT_ASSET_DIR` filenames. No Desktop source or lock schema
was retrieved through the connected repositories during this change. Therefore
`runtime-index.json` is a distribution manifest, NOT a drop-in replacement for
`pinable-desktop/internal/assets/cliproxyapi/component-lock.json`. These tools do
not invent, rewrite or relax that lock. End-to-end Wails packaging is not claimed.

## Targets and portable profile

| Target | Native runner | Archive |
| --- | --- | --- |
| windows/amd64 | windows-2025 | ZIP |
| windows/arm64 | windows-11-arm | ZIP |
| darwin/amd64 | macos-15-intel | tar.gz |
| darwin/arm64 | macos-15 | tar.gz |
| linux/amd64 | ubuntu-22.04 | tar.gz |
| linux/arm64 | ubuntu-22.04-arm | tar.gz |

All use `desktop-portable`: CGO=0, baseline CPU settings, `-trimpath`,
`-buildvcs=false`, exact go.mod toolchain, and explicit version/full source SHA/
source commit timestamp in the executable. The builder requires clean source and
does not fetch source, refresh model catalogs, or modify source files. Normal Go
dependency downloads retain checksum verification. Linux binaries are checked for
absence of a dynamic interpreter.

Dynamic-library support is platform-specific. CGO=0 removes that loader on
macOS/Linux; Windows retains the existing syscall-based DLL loader. Manifests
record this difference and `dynamic_library_plugins_tested=false`: this pipeline
certifies the core runtime, not third-party plugins. No plugins are bundled.

macOS thin amd64/arm64 executables are ad-hoc signed and verified before hashing.
They are not Universal 2, Developer ID signed or notarized. Windows executables
are unsigned. Final application signing/notarization remains the Desktop release
operator's responsibility. No signing secrets are required or changed here.

## Workflow, downloads and optional publication

Actions workflow: **Pinable desktop runtime** (`.github/workflows/pinable-runtime.yml`).

- Every main push builds current source, including merged weekly upstream syncs.
- Packaging-related PRs produce native-tested candidate downloads.
- Run workflow builds the selected branch; `version` is optional.
- Only explicit `publish_release=true` on main publishes a dedicated
`pinable-runtime-<version>` prerelease after all gates pass. Default: false.

Individual `pinable-runtime-<os>-<arch>` Actions artifacts are retained 30 days;
`pinable-runtime-all` is retained 90 days. Download URLs appear in the run summary.
Actions downloads require GitHub access. For durable public downloads use the
explicit prerelease path; published release assets are not temporary Actions files.

Publication validates the complete set again, requires main still at the tested
SHA, refuses existing tags, creates a draft, uploads without clobbering, and only
then publishes it. Failed uploads leave a draft for inspection. It never replaces
the upstream latest release or deploys. The standard Actions token is used.
The new workflow has no tag trigger. Do not manually push `pinable-runtime-*`
tags: the independent upstream release workflow has an all-tags trigger; only the
manual publication job should create these runtime releases.

## Artifact contract

Each archive is named `CLIProxyAPI_pinable_<version>_<goos>_<goarch>.zip` on
Windows or `.tar.gz` on Unix. Each has `<archive-name>.manifest.json` alongside it.
Inside are exactly three regular files:

```text
cli-proxy-api[.exe]
LICENSE
runtime.json
```

No config, .env, credentials, auth directory, plugins, web management assets or
Desktop managed launcher are included. Archives have deterministic timestamps,
member order, ownership and modes; license line endings are normalized to LF.
Build time is the source commit timestamp, not packaging wall-clock time. Archive
determinism for identical input binaries is tested; bit-identical output across
different compiler/signing hosts is not claimed.

Manifest fields cover schema/profile, version, target, source repository/full SHA/
time, Go version, build/signing policy, host contract version 1, capabilities,
license checksum, binary size/hash and archive size/hash. Embedded runtime.json
contains the same record without the recursive archive checksum. The unpacked
binary hash is distinct from the archive hash and Actions ZIP digest.

The complete artifact contains six archives, six manifests, runtime-index.json,
SHA256SUMS and validation.json. Individual artifacts also contain smoke-report.json;
the complete set embeds all six reports in validation.json.

## Desktop handoff

Approve the source SHA and **unpacked binary SHA256** against Desktop's lock before
using a candidate. Obtain manifest and archive from the same trusted run/release.
Never accept a download solely because an untrusted accompanying checksum matches.

From a CLIProxyAPI checkout with Python 3.10+:

```bash
python scripts/pinable/runtime.py verify \
--manifest /absolute/downloads/<archive-name>.manifest.json \
--expected-commit <approved-full-source-sha> \
--expected-sha256 <approved-unpacked-binary-sha256>

python scripts/pinable/runtime.py stage \
--manifest /absolute/downloads/<archive-name>.manifest.json \
--expected-commit <approved-full-source-sha> \
--expected-sha256 <approved-unpacked-binary-sha256> \
--output /absolute/component-staging/cli-proxy-api
```

Use cli-proxy-api.exe on Windows. Staging requires a new absolute destination,
never replaces a running/existing binary, rejects unsafe archive members, validates
PE/Mach-O/ELF architecture, and checks metadata and both hashes before writing.

Then use Desktop's existing single-target override in the **Desktop repository**:

```powershell
$env:CLIPROXYAPI_COMPONENT_ASSET = 'C:\components\windows-amd64\cli-proxy-api.exe'
go run ./pinable-scripts/cmd/pinable-build doctor --package
go run ./pinable-scripts/cmd/pinable-build package --platform windows
```

On Unix, export the absolute CLIPROXYAPI_COMPONENT_ASSET path and use
`--platform macos` or `--platform linux`. CodeGraph remains separately required.
An older source revision or different binary hash in Desktop's lock must still
fail. Approving this profile may require a reviewed lock/tooling change there.
Its old `components --update-lock` recipe is not guaranteed to reproduce these
hashes. This pipeline does not bypass that mismatch.

## Quality and native execution gates

The normal CGO-profile full Go suite and existing eight sync-script regressions
run on Linux. Distribution and publication-policy tests use isolated fixtures and
mocks; they do not count as actual runtime or GitHub-release execution.

Each of six native architectures runs portable host regressions, builds the
server, verifies its archive and exercises the exact packaged executable:

- both version flags without config or file creation;
- new and legacy discovery output as valid JSON;
- authenticated contract version, full SHA, timestamp, PID and runtime version;
- public/management credential separation and rejection of unauthenticated shutdown;
- runtime key surviving a proven atomic config-key rotation without persistence;
- authenticated loopback shutdown and monitored parent exit;
- macOS signature validity after extraction.

The complete download is only emitted after quality and all six native jobs pass.
No live provider accounts/API keys are used; local tests are not live API validation.
Existing repository CI remains enabled on the actual PR merge result.

## Atomic config replacement fix

Native smoke checks exposed a pre-existing Windows/Linux issue: atomically replacing
config.yaml detached the file watch. The watcher now also monitors its containing
directory, retaining the original file watch for missing-file errors and direct
symlink-target writes. Existing path filters ignore unrelated siblings.

A real-filesystem regression requires two consecutive replacements and a subsequent
in-place write to finish reloading, using observable hash/port completion rather
than guessed sleeps. A second test checks unrelated siblings are ignored. The
packaged-binary smoke continues using atomic replacement: no reload assertion was
removed or weakened. This small watcher fix is the only production-code change.

## Local commands

```bash
python -m unittest discover -s scripts/pinable -p 'test_*runtime.py' -v
python scripts/pinable/runtime.py build --target linux/amd64 --output /tmp/new-runtime-output
python scripts/pinable/smoke_runtime.py --artifact-dir /tmp/new-runtime-output
```

Use exactly the Go version declared in go.mod. Native macOS is required for signing.
The optional safe `--version` label defaults to source timestamp plus SHA. No pip
modules, Node, Docker, GoReleaser or zip/unzip executables are required by these
packaging tools. Do not commit generated runtimes.
Loading
Loading