diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 0000000..2f9a9b1 --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,163 @@ +name: CI tests +on: + push: + branches: [develop] + pull_request: + branches: [develop, main] +permissions: + contents: read +concurrency: + group: pr-tests-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24.11.0' + - run: node --test scripts/release/*.test.mjs scripts/run-build-command.test.js + + desktop: + runs-on: ubuntu-24.04 + env: + PUPPETEER_SKIP_DOWNLOAD: 'true' + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.data/pr-bun-cache + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + submodules: recursive + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24.11.0' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: '1.3.9' + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ${{ env.BUN_INSTALL_CACHE_DIR }} + key: pr-bun-ubuntu24-1.3.9-${{ hashFiles('aionui/bun.lock') }} + - uses: ./.github/actions/install-desktop-dependencies + - name: Prepare the pinned Chrome DevTools dependency and browser MCP bundle + run: node scripts/prepare-chrome-devtools-mcp.js + env: + NODE_OPTIONS: --max-old-space-size=4096 + - run: bun run test + working-directory: aionui + + rust: + strategy: + fail-fast: false + matrix: + include: + - { component: aioncore, toolchain: '1.95.0' } + - { component: aionrs, toolchain: '1.96.1' } + runs-on: ubuntu-24.04 + env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_TEST_DEBUG: '0' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + submodules: recursive + persist-credentials: false + - run: rustup toolchain install ${{ matrix.toolchain }} --profile minimal + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ${{ matrix.component }}/target + key: pr-ubuntu24-${{ matrix.component }}-${{ matrix.toolchain }}-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock') }}-${{ github.sha }} + restore-keys: pr-ubuntu24-${{ matrix.component }}-${{ matrix.toolchain }}-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock') }}- + - run: cargo +${{ matrix.toolchain }} test --locked --workspace --lib + working-directory: ${{ matrix.component }} + + secrets-vault: + name: Secrets vault (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + CARGO_BUILD_JOBS: '2' + CARGO_PROFILE_TEST_DEBUG: '0' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + submodules: recursive + persist-credentials: false + - run: rustup toolchain install 1.95.0 --profile minimal + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + aioncore/target + key: secrets-${{ runner.os }}-1.95-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock') }}-${{ github.sha }} + restore-keys: secrets-${{ runner.os }}-1.95-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock') }}- + - name: Install Linux native prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends build-essential cmake pkg-config libssl-dev libasound2-dev libudev-dev + - name: Verify encrypted vault and history masking + working-directory: aioncore + run: cargo +1.95.0 test --locked -p aionui-secrets + - name: Verify human transport boundaries + working-directory: aioncore + run: cargo +1.95.0 test --locked -p aionui-app --lib secrets::access_tests + - name: Verify scoped Secrets HTTP integration + working-directory: aioncore + run: cargo +1.95.0 test --locked -p aionui-app --test secrets_e2e + - name: Verify sensitive event projections + working-directory: aioncore + run: cargo +1.95.0 test --locked -p aionui-ai-agent --lib capability::sensitive + - name: Verify ACP recovered Secrets confirmations + working-directory: aioncore + run: cargo +1.95.0 test --locked -p aionui-ai-agent --lib secrets_sdk_permission_roundtrip + - name: Verify Codex Secrets approval wire + working-directory: aioncore + run: cargo +1.95.0 test --locked -p aionui-session --lib secrets_wire_ + - name: Select native Rust toolchain + run: rustup toolchain install 1.96.1 --profile minimal + - name: Verify native MCP approvals and raw delivery + working-directory: aionrs + run: cargo +1.96.1 test --locked -p aion-agent sensitive_ + + secrets-headless: + name: Secrets passphrase (headless container) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + submodules: recursive + persist-credentials: false + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: | + .data/secrets-container-cargo + .data/secrets-container-target + key: secrets-headless-1.95-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock') }}-${{ github.sha }} + restore-keys: secrets-headless-1.95-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock') }}- + - name: Verify passphrase recovery without a desktop key store + run: | + mkdir -p .data/secrets-container-cargo .data/secrets-container-target + docker run --rm \ + --volume "$PWD:/workspace" \ + --volume "$PWD/.data/secrets-container-cargo:/cache" \ + --volume "$PWD/.data/secrets-container-target:/target" \ + --workdir /workspace/aioncore \ + --env CARGO_HOME=/cache \ + --env CARGO_TARGET_DIR=/target \ + --env CARGO_BUILD_JOBS=2 \ + --env CARGO_PROFILE_TEST_DEBUG=0 \ + rust:1.95.0-bookworm \ + bash -ec 'apt-get update && apt-get install --yes --no-install-recommends cmake pkg-config libssl-dev libasound2-dev libudev-dev && cargo test --locked -p aionui-secrets' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81b3fa1..44a3d7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,22 +6,25 @@ on: workflow_dispatch: inputs: macos: - description: 'Build signed/notarized macOS arm64 installers.' + description: 'Build Developer ID signed and notarized macOS arm64 installers.' type: boolean default: true windows: description: 'Build the unsigned Windows x64 installer.' type: boolean default: true - linux: - description: 'Build unsigned Linux x64 desktop packages.' + linux-flatpak: + description: 'Build the Linux x64 Flatpak bundle.' + type: boolean + default: true + linux-deb: + description: 'Build the Linux x64 Debian package.' + type: boolean + default: true + linux-appimage: + description: 'Build the Linux x64 AppImage in a container.' type: boolean default: true - linux_packages: - description: 'Linux formats for manual verification; publication requires all.' - type: choice - options: [all, flatpak, deb] - default: all docker: description: 'Build and smoke-test all Linux VPS images.' type: boolean @@ -35,13 +38,13 @@ permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: release-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && !inputs.publish && github.run_id || 'publication' }} cancel-in-progress: false jobs: preflight: runs-on: ubuntu-24.04 - environment: ${{ (github.event_name == 'push' || inputs.macos || inputs.publish) && 'release' || 'ci' }} + environment: ${{ (github.event_name == 'push' || inputs.macos) && 'release' || 'ci' }} outputs: version: ${{ steps.source.outputs.version }} tag: ${{ steps.source.outputs.tag }} @@ -73,8 +76,9 @@ jobs: RELEASE_PUBLISH: ${{ github.event_name == 'push' || inputs.publish }} RELEASE_BUILD_MACOS: ${{ github.event_name == 'push' || inputs.macos }} RELEASE_BUILD_WINDOWS: ${{ github.event_name == 'push' || inputs.windows }} - RELEASE_BUILD_LINUX: ${{ github.event_name == 'push' || inputs.linux }} - RELEASE_LINUX_PACKAGES: ${{ github.event_name == 'push' && 'all' || inputs.linux_packages }} + RELEASE_LINUX_FLATPAK: ${{ github.event_name == 'push' || inputs['linux-flatpak'] }} + RELEASE_LINUX_DEB: ${{ github.event_name == 'push' || inputs['linux-deb'] }} + RELEASE_LINUX_APPIMAGE: ${{ github.event_name == 'push' || inputs['linux-appimage'] }} RELEASE_BUILD_DOCKER: ${{ github.event_name == 'push' || inputs.docker }} REQUIRE_SIGNING_SECRETS: 'true' MAC_SIGNING_READY: ${{ secrets.MAC_CERTIFICATE_P12_BASE64 != '' && secrets.MAC_CERTIFICATE_PASSWORD != '' && secrets.MAC_SIGNING_IDENTITY != '' && secrets.MAC_TEAM_ID != '' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_APP_PASSWORD != '' }} @@ -168,7 +172,7 @@ jobs: desktop-${{ matrix.target }}-rust1.95.0-node24.11.0-bun1.3.9-${{ hashFiles('aioncore/Cargo.lock', 'aionrs/Cargo.lock', 'aionui/bun.lock', 'aionui/scripts/memory/locks/*.json') }}- desktop-${{ matrix.target }}-rust1.95.0-node24.11.0-bun1.3.9- - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - if: runner.os == 'Linux' && needs.preflight.outputs.linux_packages != 'deb' + if: runner.os == 'Linux' && (needs.preflight.outputs.linux_packages == 'all' || contains(needs.preflight.outputs.linux_packages, 'flatpak')) id: flatpak-cache with: path: ${{ runner.temp }}/flatpak-user @@ -182,16 +186,16 @@ jobs: run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends build-essential cmake pkg-config libssl-dev - if [ "$RELEASE_LINUX_PACKAGES" != deb ]; then + if [[ "$RELEASE_LINUX_PACKAGES" == all || "$RELEASE_LINUX_PACKAGES" == *flatpak* ]]; then sudo apt-get install --yes --no-install-recommends flatpak flatpak-builder dbus-x11 fi - name: Install locked dependencies uses: ./.github/actions/install-desktop-dependencies - name: Prepare configured Flatpak runtimes and verify sandbox - if: runner.os == 'Linux' && needs.preflight.outputs.linux_packages != 'deb' + if: runner.os == 'Linux' && (needs.preflight.outputs.linux_packages == 'all' || contains(needs.preflight.outputs.linux_packages, 'flatpak')) run: dbus-run-session -- node scripts/release/flatpak-preflight.mjs --install - name: Prepare ephemeral Developer ID keychain and notarization - if: runner.os == 'macOS' + if: runner.os == 'macOS' && matrix.environment == 'release' env: MAC_CERTIFICATE_P12_BASE64: ${{ secrets.MAC_CERTIFICATE_P12_BASE64 }} MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} @@ -201,7 +205,7 @@ jobs: MAC_NOTARY_APP_PASSWORD: ${{ secrets.MAC_NOTARY_APP_PASSWORD }} run: node scripts/release/ci-macos-signing.mjs setup - name: Build macOS installers - if: runner.os == 'macOS' + if: runner.os == 'macOS' && matrix.environment == 'release' run: | node scripts/release/build.mjs --signed node scripts/release/ci-macos-signing.mjs validate aionui/out/mac-arm64/eLLMsha.app @@ -218,7 +222,7 @@ jobs: if: runner.os == 'Linux' run: | test "$(node -p process.arch)" = x64 - if [ "$RELEASE_LINUX_PACKAGES" = deb ]; then + if [[ "$RELEASE_LINUX_PACKAGES" != all && "$RELEASE_LINUX_PACKAGES" != *flatpak* ]]; then node scripts/release/build.mjs --unsigned else dbus-run-session -- node scripts/release/build.mjs --unsigned @@ -256,7 +260,7 @@ jobs: path: ${{ runner.temp }}/flatpak-user key: ${{ steps.flatpak-cache.outputs.cache-primary-key }} - name: Remove temporary signing material - if: always() && runner.os == 'macOS' + if: always() && runner.os == 'macOS' && matrix.environment == 'release' run: node scripts/release/ci-macos-signing.mjs cleanup images: diff --git a/CI.md b/CI.md new file mode 100644 index 0000000..5625f9e --- /dev/null +++ b/CI.md @@ -0,0 +1,45 @@ +# Building eLLMsha with GitHub Actions + +Use the root repository's **Release installers and VPS images** workflow for development builds as well as releases. A development build produces downloadable run artifacts. A release publishes installers to GitHub Releases and images to GHCR. All branches use the same production packaging and verification; only the source ref, platform selection and publication choice differ. Test profiles and synthetic credentials are created separately during QA and are never embedded in installers. + +## Build just what you need + +1. Open [the build workflow](https://github.com/ShiftHackZ/ellmsha/actions/workflows/release.yml) and choose **Run workflow**. +2. Select the branch to build. Use `develop` for the integration build or select your pushed feature branch to test its exact source. The selected ref must contain this workflow version. +3. Enable the outputs you want in the table below. All six default to **on**. Leave **publish** off. +4. Open the finished run and download the matching archive under **Artifacts**. Desktop archives include the installer, SHA-256 sums and a JSON manifest with the root and component commits. Run artifacts are retained for 14 days. + +| Input | Output | +| --- | --- | +| `macos` | Apple silicon DMG and ZIP. Every branch uses the same Developer ID signing and notarization as main. | +| `windows` | Unsigned Windows x64 installer | +| `linux-flatpak` | Linux x64 Flatpak bundle | +| `linux-deb` | Linux x64 Debian package | +| `linux-appimage` | Linux x64 portable AppImage, packaged inside a pinned Node container | +| `docker` | Four Linux/amd64 VPS images and their smoke checks | + +The three Linux switches are independent of each other and of `docker`. A combined Linux build compiles the desktop payload once. Only Flatpak requests install the Flatpak SDK. The AppImage container wraps the unpacked application and reuses the electron-builder download cache. + +For example, this builds **only AppImage** on a pushed feature branch: + +```sh +gh workflow run release.yml --repo ShiftHackZ/ellmsha --ref feature/my-change \ + -f macos=false -f windows=false -f docker=false \ + -f linux-flatpak=false -f linux-deb=false -f linux-appimage=true -f publish=false +``` + +To test Flatpak alone, swap the Flatpak and AppImage values. Use `gh run list --repo ShiftHackZ/ellmsha --workflow release.yml --branch feature/my-change` to find the run. + +## What runs on a pull request or develop push + +Every direct push to `develop` and root PR targeting `develop` or `main` runs release contracts, desktop UI tests and Rust workspace library tests against the pushed commit or PR merge result and its pinned submodules. Changes to the shared-terminal paths also trigger the existing cross-platform PTY suite. PR jobs use read-only repository permissions. Packaging is available separately through manual dispatch. + +Desktop builds cache compiled Rust dependencies, package-manager downloads, Electron and electron-builder downloads, and the Flatpak installation when selected. Docker maintains its own build cache. GitHub scopes cache reuse by ref. A cache miss on a new branch is expected. Inspect the earliest failed step and retry failed jobs after resolving transient runner issues. + +## Publish a release + +1. Validate the desired root and component commits on `main`. Build the candidate with **publish off** and test its installers. +2. Check the release notes and the package/About version. Push the matching immutable `vYYYY.MM.DD-patch.N` tag at that exact commit. The tag push selects **all six outputs** and starts publication automatically. +3. Let that run finish. Its publish job checks the exact tag, Kyiv release date, source manifests, installer hashes and tested images before publishing. Verify the distributed assets from the [release page](https://github.com/ShiftHackZ/ellmsha/releases) because publication rebuilds them. + +Manual publication is reserved for the exact tagged commit on main or the matching tag and requires all six outputs. Avoid dispatching a second publication for a tag that already triggered a run. Keep signing secrets in GitHub's **release** environment. Set its deployment branch policy to **All branches** so manually selected refs use the same signing path; retain any required reviewer protections. Only repository collaborators can dispatch these builds. Signing failure stops the build—there is no unsigned fallback. PR test workflows do not receive signing secrets. See [signing and build prerequisites](scripts/release/README.md) and [branching strategy](GIT.md). diff --git a/GIT.md b/GIT.md new file mode 100644 index 0000000..1e6bf79 --- /dev/null +++ b/GIT.md @@ -0,0 +1,50 @@ +# Branching strategy + +The root repository chooses the exact combination of components to ship. Each submodule entry is a commit ID. A push to a component branch becomes part of eLLMsha only after a root commit updates its gitlink. + +| Purpose | Root repository | Maintained forks | +| --- | --- | --- | +| Stable source and releases | `main` | `ellmsha-main` | +| Development and integration | `develop` | `ellmsha-develop` | +| Work under review | `feature/` | `feature/` | + +## Deliver a feature + +1. Branch from the development line of each affected repository. Push the feature to a same-named remote branch and open a PR to the development branch. +2. Integrate component changes first. Update the root gitlinks to reachable, tested SHAs. Stage only the intended submodule paths and root files. Leave private runtime directories and BMAD output outside Git. +3. Open the root PR to `develop`. PR tests run automatically. Use the manual workflow to produce selected development packages on the PR branch or on `develop`. +4. For promotion, merge or fast-forward the intended fork commits into `ellmsha-main`, then open the root promotion PR from `develop` to `main`. Preserve the tested SHAs or update the gitlinks and re-test if the merge changes them. +5. Tag the accepted root commit to publish. For a hotfix, branch from stable and bring the fix back to development after release. + +```mermaid +flowchart LR + F["Fork feature branch"] -->|PR| FD["ellmsha-develop"] + FD -->|promote| FM["ellmsha-main"] + FD -->|pin component SHA| RF["Root feature branch"] + RF -->|PR and tests| D["develop"] + D -->|promotion PR and tests| M["main"] + FM -. verify pinned SHA .-> M + M -->|immutable tag| R["Published release"] +``` + +## Check out the exact source + +```sh +git fetch origin +git switch develop +git submodule sync --recursive +git submodule update --init --recursive +git submodule status --recursive +``` + +This checks out each fork at the commit recorded by the root. The branch hints in `.gitmodules` describe the stable upstreams for explicit remote updates. Ordinary checkouts and CI follow the gitlinks. To edit a fork, create a feature branch from its development ref instead of committing on the detached checkout. + +```mermaid +flowchart LR + B["main, develop or a feature branch"] -->|manual dispatch| A["Selected build artifacts"] + P["PR to develop or main"] --> T["Read-only tests"] + V["Matching release tag"] --> C["All desktop formats and Docker"] + C -->|verification succeeds| G["GitHub Releases and GHCR"] +``` + +See [CI.md](CI.md) for the workflow inputs and an AppImage-only example. diff --git a/README.md b/README.md index 632f6b7..ad3abb0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **Cloud models · Local models · Persistent context · Tools you can extend** -[How it works](docs/architecture.md) · [Working effectively](docs/workflows.md) · [Build desktop](scripts/release/README.md) · [VPS deployment](docs/vps-deployment.md) · [Release status](docs/release-status.md) +[How it works](docs/architecture.md) · [Working effectively](docs/workflows.md) · [Builds and CI/CD](CI.md) · [Branch strategy](GIT.md) · [VPS deployment](docs/vps-deployment.md) · [Release status](docs/release-status.md) @@ -57,9 +57,12 @@ Installed macOS test profile after rebuilding its fixture with a 768-dimensional

Download eLLMsha for macOS — Apple silicon Download eLLMsha for Windows — x64 + Download Linux x64 Flatpak + Download Linux x64 DEB + Download Linux x64 AppImage

-Choose the **macOS Apple silicon DMG** or **Windows x64 EXE** on the [latest release page](https://github.com/ShiftHackZ/ellmsha/releases/latest). macOS releases are Developer ID signed and notarized; Windows installers are currently unsigned. +Choose the **macOS Apple silicon DMG** or **Windows x64 EXE** on the [latest release page](https://github.com/ShiftHackZ/ellmsha/releases/latest). macOS releases are Developer ID signed and notarized; Windows installers are currently unsigned. For Linux x64, look for the `.flatpak`, `.deb` or `.AppImage` asset. Releases predating Linux packaging may omit these formats. Development packages are available as [GitHub Actions artifacts](CI.md#build-just-what-you-need). 1. Install the app and open a project folder. Attach additional repositories from the Files panel when needed. 2. Choose an agent and model. For local inference, configure Ollama and download a suitable model. @@ -195,7 +198,9 @@ The catalog supports enabling plugins, connecting accounts and installing additi | --- | --- | --- | | macOS | ✅ arm64 / Apple silicon | ✅ Linux amd64 via Docker Desktop; emulated on Apple silicon | | Windows | ✅ x64 / x86-64 | ✅ Linux amd64 via Docker Desktop / WSL2 | -| Linux | ❌ No published desktop package | ✅ amd64 / x86-64, native Docker Engine | +| Linux | ✅ x64 / x86-64 · Flatpak, DEB and AppImage ([development builds](CI.md#build-just-what-you-need)) | ✅ amd64 / x86-64, native Docker Engine | + +GitHub Actions has built all three Linux desktop formats. Native Linux desktop launch testing is still pending. Docker packages are **Linux amd64** images. Other host architectures need [emulation](https://docs.docker.com/build/building/multi-platform/); Linux arm64 images are not currently published. See [releases](https://github.com/ShiftHackZ/ellmsha/releases) for available installers and image versions. diff --git a/aioncore b/aioncore index 502b2d7..e88c6b1 160000 --- a/aioncore +++ b/aioncore @@ -1 +1 @@ -Subproject commit 502b2d7bac62a20a6931d0b53a319319760e2d9a +Subproject commit e88c6b14e23b407a3e226dd91d7e8ebc088e5356 diff --git a/aionrs b/aionrs index 40d6077..bd2020a 160000 --- a/aionrs +++ b/aionrs @@ -1 +1 @@ -Subproject commit 40d6077de789267480774be1e91816e6ed4788d3 +Subproject commit bd2020ac500d894b7a83be7066fe941723bae314 diff --git a/aionui b/aionui index c39f64d..64e2ecb 160000 --- a/aionui +++ b/aionui @@ -1 +1 @@ -Subproject commit c39f64d8e639e5ffcc41a579a048f450c507e8c3 +Subproject commit 64e2ecb1e13ce07294e5ab0449c7e9cef1a85568 diff --git a/docs/assets/download-linux-appimage.svg b/docs/assets/download-linux-appimage.svg new file mode 100644 index 0000000..96a9d3e --- /dev/null +++ b/docs/assets/download-linux-appimage.svg @@ -0,0 +1,12 @@ + + Download eLLMsha for Linux as AppImage + x64. Open the latest eLLMsha release on GitHub. + + + + + DOWNLOAD FOR + Linux + AppImage · x64 + + diff --git a/docs/assets/download-linux-deb.svg b/docs/assets/download-linux-deb.svg new file mode 100644 index 0000000..3de3ffd --- /dev/null +++ b/docs/assets/download-linux-deb.svg @@ -0,0 +1,12 @@ + + Download eLLMsha for Linux as DEB + x64. Open the latest eLLMsha release on GitHub. + + + + + DOWNLOAD FOR + Linux + DEB · x64 + + diff --git a/docs/assets/download-linux-flatpak.svg b/docs/assets/download-linux-flatpak.svg new file mode 100644 index 0000000..9508862 --- /dev/null +++ b/docs/assets/download-linux-flatpak.svg @@ -0,0 +1,12 @@ + + Download eLLMsha for Linux as Flatpak + x64. Open the latest eLLMsha release on GitHub. + + + + + DOWNLOAD FOR + Linux + Flatpak · x64 + + diff --git a/plugins/secrets/README.md b/plugins/secrets/README.md new file mode 100644 index 0000000..5511428 --- /dev/null +++ b/plugins/secrets/README.md @@ -0,0 +1,39 @@ +# Automation Secrets + +Secrets is an optional built-in plugin for credentials intentionally shared with automation. Enable it in **Settings → Plugins → Secrets**, create a vault with a passphrase, and add records there or from the chat sidebar's key icon. Temporary chats can access global records. Standard project chats can access global records and that project's records. Projects retained after removal from the active picker remain manageable in Settings; they are never reassigned by a matching name or path. + +Records support login, API key, secure note and structured fields. Add explicit service, environment, tenant, role and exact website/application/CLI target bindings. Different environments may intentionally share one record or use separate records. Required or unknown MFA means the agent must ask the human to finish authentication. Codes, seeds, recovery codes and passkeys do not belong in this vault. + +## Agent access and approvals + +The plugin exposes global/project list, read, write and delete tools, plus history search and masking. Tools use the existing Aion approval flow, including its current mode and once/session/persistent options. Approval does not widen conversation scope. The backend rechecks owner, project, plugin state and vault identity before executing. No separate Secrets approval setting is introduced. + +Explicit reads return **raw usable credentials to the model/provider**. Owned tool cards and canonical tool projections hide sensitive arguments/results, but the model may echo them, use them in another tool, or transmit them to a website. Provider retention, terminal/browser logs and existing exports are outside this vault. Store only credentials you intentionally want automation to use. Do not write them to memory, rules or skill files. + +Metadata listings contain titles and environment bindings, never field values. The human editor masks values until reveal/copy, and clears raw state on lock, chat switch and plugin disable. Record updates/deletes require the current metadata incarnation and revision; a conflict requires refreshing and reviewing the latest version. + +## Storage, locking and recovery + +Each owner has an isolated encrypted vault in the backend data directory's `secrets-vault.sqlite`. AES-256-GCM encrypts record values and metadata; an Argon2id-derived key wraps a random vault key. The passphrase is not recoverable. Keep an encrypted backup and its passphrase if you need recovery. Restore replaces the owner's vault only after validating the complete backup; it does not merge records. + +The encrypted stored snapshot (records plus permanent idempotency receipts) and a 1 KiB deletion reserve per live record share a 32 MiB limit. Ciphertext uses fixed-length base64 encoding; the reserve guarantees space to delete each existing record and keep its retry receipt. Receipts are never silently pruned. Operation UUID spellings are normalized before retry matching. Writes and restores that exceed it are rejected before replacing the vault. Base64 backup files remain below 43 MiB and fit the 64 MiB import limit, so a successfully committed vault can always be exported and restored. Individual records remain limited to 64 KiB. + +Use **Lock** to remove the decrypted key from backend memory. Idle keys expire after fifteen minutes; background metadata polling does not prolong that timer. Desktop lock-screen events also request a lock and clear editor state. Changing the passphrase preserves the records and invalidates the old optional device wrapper. Restarting the backend requires unlocking again. + +Desktop users can opt in to an OS-protected device wrapper. It uses Electron safeStorage and is unavailable when Linux has no supported secret-service backend; there is no plaintext fallback. Forgetting the device removes only that convenience wrapper. WebUI uses the passphrase flow and cannot export or import a raw device key. + +## Finding and masking previous chat credentials + +History search is opt-in and scoped to the current project, current chat, or owned temporary history. It scans user text by default; assistant/tool payloads require explicit opt-in. Search returns heuristic candidates and continuation cursors. Follow every page if a complete bounded scan is needed; oversized messages are reported as skipped. A zero-result page is not proof that history contains no credentials. + +Select candidate handles explicitly before masking. The backend validates their caller, scope, expiry and message revisions and applies the selected payload replacements atomically. Actively written targets are rejected. Later stale stream updates cannot restore the old canonical content. Future canonical exports/forks see the edited text; existing forks, exports, SQLite WAL/backups, model/provider retention and live runtime context are **not erased**. Masking does not interrupt a running agent or rotate a leaked credential. + +## Remote WebUI deployment + +The Core HTTP listener does not terminate TLS itself. Secrets WebUI requests are accepted directly only from an authenticated loopback connection with a loopback Host/Origin. Cookie `Secure`, `Forwarded` and `X-Forwarded-Proto` are not accepted as evidence of encryption. + +For a remote WebUI, use a TLS-only reverse proxy on the same host and bind the Core origin to loopback. Configure a private random capability of at least 64 characters in `AIONUI_SECRETS_TLS_PROXY_CAPABILITY` for the backend process and configure the proxy to **overwrite** the `X-Ellmsha-Secrets-Tls-Proxy` request header with that value. The proxy must reject unencrypted requests, strip any client-supplied copy of the capability header, and keep the value out of client code, responses and access logs. Do not expose the origin port or proxy an HTTP listener to it. The backend checks the actual socket peer is loopback and requires the capability on every WebUI Secrets request in this mode. Normal JWT authentication and CSRF checks remain required. A missing or incorrect capability fails closed; it is not an authentication substitute. + +The desktop human capability is separately generated per launch, held by Electron main, and consumed by the backend before spawning agents. Runtime tokens and renderer calls cannot use the device-key routes. + +Restore rotates an authenticated UUID incarnation while keeping record IDs, revisions and operation receipts. Update/delete requests must include the incarnation and revision returned by current metadata. Stale mutations are rejected before receipt lookup, including when a restored branch reuses a revision. Re-read and re-approve after restore; idempotent retries remain deterministic within an incarnation. The fixed 36-byte incarnation preserves the snapshot/deletion-reserve size bound. diff --git a/plugins/secrets/aion-extension.json b/plugins/secrets/aion-extension.json new file mode 100644 index 0000000..9c3d9db --- /dev/null +++ b/plugins/secrets/aion-extension.json @@ -0,0 +1,28 @@ +{ + "name": "secrets", + "version": "1.0.0", + "displayName": "Secrets", + "description": "Encrypted automation credentials, scoped to global and project chats. Raw reads are delivered to the model through existing Aion tool approvals.", + "permissions": { + "storage": true, + "network": { + "allowedDomains": [], + "reasoning": "Local encrypted vault only." + }, + "shell": false, + "filesystem": "extension-only", + "active_user": true + }, + "contributes": { + "skills": [ + { + "name": "automation-secrets", + "description": "Use automation secrets with exact scope, environment, tenant and human MFA.", + "path": "skills/secrets/SKILL.md" + } + ] + }, + "author": "eLLMsha", + "license": "MIT", + "apiVersion": "1.0.0" +} diff --git a/plugins/secrets/skills/secrets/SKILL.md b/plugins/secrets/skills/secrets/SKILL.md new file mode 100644 index 0000000..e46163d --- /dev/null +++ b/plugins/secrets/skills/secrets/SKILL.md @@ -0,0 +1,18 @@ +--- +name: automation-secrets +description: Use scoped automation credentials with existing Aion approvals and human MFA. +--- + +# Automation Secrets + +Use ellmsha_global_secrets_list and, only in Standard project chats, ellmsha_project_secrets_list to find an exact record ID and service/environment/tenant binding. Duplicate names are not identity. Never fall back to another project or global record without explicit selection. + +Raw reads intentionally deliver passwords to the model. Use only the selected credential for the requested action. Do not echo secret values, write them to memory, logs or unrelated files, or copy them into tool approval descriptions. Provider transcripts and agent-authored echoes may retain plaintext. + +Reuse normal Aion approval options; setup/unlock is not tool authorization. Locked vaults require the human to unlock in Secrets. Never request a master passphrase through tools. + +For required or unknown MFA, pause and use existing ellmsha_ask_user. Prefer direct user approval on the website/device. If a code must be relayed, mark the question isSecret and disclose that the model receives the answer. Never save codes, TOTP seeds, recovery material or passkey private keys. A received code is not proof authentication succeeded. + +Writes use UUID operation IDs, updates/deletes use the exact metadata incarnation and revision (expected_incarnation/expected_revision); updates use patches; omitted fields remain, explicit null removes a field. Bindings may intentionally share a secret. Distinct credentials need distinct records. No implicit scope moves. A restore changes the incarnation: re-read metadata and obtain a new approval before retrying a mutation prepared before restore. + +History search returns unverified candidates from canonical messages only. Save selected values through ordinary scoped write before masking if requested. Mask only selected candidate handles with a UUID operation ID. Masking does not erase active model context, provider/native replay, attachments, old exports/backups, memory, WAL, or existing fork copies. Do not interrupt sessions or claim complete erasure. diff --git a/scripts/release/README.md b/scripts/release/README.md index de42a8e..18dd901 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -1,5 +1,7 @@ # Native desktop builds +For development builds, use [GitHub Actions](../../CI.md). The local commands below support packaging diagnostics. + ## Prepare the checkout Use the maintained fork revisions recorded by this checkout. Release commits pin published component revisions; do not replace them with upstream to get a clean clone building. See [release status](../../docs/release-status.md). @@ -28,13 +30,13 @@ spctl --assess --type execute --verbose=2 aionui/out/mac-arm64/eLLMsha.app node scripts/release/verify-artifacts.mjs ``` -The build script never publishes. Use the same signed build, stapled-ticket validation and Gatekeeper commands as CI for macOS release acceptance; stop if any step fails. `verify-artifacts.mjs` checks installer presence and hashes, so it does not replace the signature, notarization or native runtime checks. On macOS, an external host signing configuration opts this machine into signed builds by default; without it, the build is unsigned. `--signed` requires that configuration. The script compiles the local Rust backend with the locked dependency graph, then invokes the existing desktop packager, including its bundled-resource checks. Artifacts go to `aionui/out`. Windows and Linux release builds use `--unsigned`. CI builds Linux desktop Flatpak/DEB packages independently from Linux VPS images. +The build script never publishes. Use the same signed build, stapled-ticket validation and Gatekeeper commands as CI for macOS release acceptance; stop if any step fails. `verify-artifacts.mjs` checks installer presence and hashes, so it does not replace the signature, notarization or native runtime checks. On macOS, an external host signing configuration opts this machine into signed builds by default; without it, the build is unsigned. `--signed` requires that configuration. The script compiles the local Rust backend with the locked dependency graph, then invokes the existing desktop packager, including its bundled-resource checks. Artifacts go to `aionui/out`. Windows and Linux release builds use `--unsigned`. CI builds Linux desktop Flatpak, DEB and AppImage packages independently from Linux VPS images. | Build host | Artifacts | | --- | --- | | macOS arm64 / x64 | DMG and ZIP | | Windows arm64 / x64 | NSIS EXE installer | -| Linux arm64 / x64 | Flatpak bundle and DEB | +| Linux arm64 / x64 | Flatpak, DEB and container-built AppImage | Use a native host for each OS and architecture. Cross-compilation is deliberately rejected by the desktop runtime preparer. Install Rust (the repository toolchain), Node.js/npm, Bun, Python and the platform C/C++ toolchain first. Computer Use additionally needs Swift on macOS or Go on Windows/Linux. Linux Flatpak builds require `flatpak`, `flatpak-builder`, the Freedesktop SDK/Platform 26.08 and Electron2 BaseApp 26.08. @@ -51,7 +53,7 @@ node scripts/release/build.mjs --unsigned node scripts/release/verify-artifacts.mjs ``` -Use `RELEASE_LINUX_PACKAGES=deb` for DEB only or `all` for both formats; `all` is the default. Keep the same selection for preparation, building, verification and staging. The preparation helper reads the actual Electron Builder configuration, adds the official Flathub remote to the isolated user installation, installs or updates its configured runtime/SDK/base, and exercises the SDK sandbox. It skips Flatpak commands for DEB-only builds. On a headless host without a session bus, run preparation/build commands through `dbus-run-session -- node ...`; CI does this automatically for Flatpak preparation and packaging. The helper does not modify the application's profile or install eLLMsha itself. +Select `deb`, `flatpak`, `appimage`, or a comma-separated subset such as `deb,appimage`. The default `all` builds all three. AppImage packaging requires Docker on the native Linux host. Keep the same selection for preparation, building, verification and staging. The preparation helper reads the actual Electron Builder configuration, adds the official Flathub remote to the isolated user installation, installs or updates its configured runtime/SDK/base, and exercises the SDK sandbox. It skips Flatpak commands for DEB-only builds. On a headless host without a session bus, run preparation/build commands through `dbus-run-session -- node ...`; CI does this automatically for Flatpak preparation and packaging. The helper does not modify the application's profile or install eLLMsha itself. `FLATPAK_USER_DIR` is Flatpak's supported override for the per-user installation; CI uses it to cache runtimes outside the checkout. See the [Flatpak command reference](https://docs.flatpak.org/en/latest/flatpak-command-reference.html) and [Electron Builder's v26 Flatpak guide](https://www.electron.build/v26/docs/flatpak/). Packaging produces a single-file bundle, not a Flathub repository submission. @@ -90,25 +92,7 @@ The final release workflow rebuilds the installers; it does not promote the exac ## GitHub Actions release pipeline -Open **Actions → Release installers and VPS images → Run workflow**. The four independent switches `macos`, `windows`, `linux` and `docker` default to `true`; `publish` defaults to `false`. Set any combination for verification. `linux_packages` selects `all` (Flatpak and DEB), `flatpak`, or `deb`, and defaults to `all`. After preflight, selected builds run independently in parallel; unselected desktop platforms are omitted from the job matrix. Selecting no builds is rejected. Verification never publishes a release or pushes GHCR images. - -| Manual run | `macos` | `windows` | `linux` | `linux_packages` | `docker` | `publish` | -| --- | --- | --- | --- | --- | --- | --- | -| All builds, verification | `true` | `true` | `true` | `all` | `true` | `false` | -| macOS only | `true` | `false` | `false` | `all` | `false` | `false` | -| Windows only | `false` | `true` | `false` | `all` | `false` | `false` | -| Linux Flatpak only | `false` | `false` | `true` | `flatpak` | `false` | `false` | -| Linux DEB only | `false` | `false` | `true` | `deb` | `false` | `false` | -| Docker only | `false` | `false` | `false` | `all` | `true` | `false` | -| Full publication | `true` | `true` | `true` | `all` | `true` | `true` | - -A release-tag push automatically selects **all four builds, both Linux formats and publication**. Complete candidate acceptance before pushing the matching immutable `vYYYY.MM.DD-patch.N` tag. Push it once; do not also dispatch a publication for that tag while its run is active. Manual `publish=true` also requires all four switches and `linux_packages=all`, and must run from `main` or the matching release tag at the exact tagged commit. Both preflight and publication verify tag/source equality. Partial verification artifacts cannot satisfy full publication. - -Manual verification on `main` populates default-branch caches that later release-tag runs can restore. Platform-specific fallback keys let Cargo and dependency installation reuse unchanged inputs; selecting only Flatpak still uses the Linux cache. Retry only failed jobs/dependents when successful branches already exist in the same run. The earlier selective macOS/Windows/Docker pipeline passed hosted Docker-only [run 38522234500](https://github.com/ShiftHackZ/ellmsha/actions/runs/38522234500) and full publication [run 38526765682, attempt 2](https://github.com/ShiftHackZ/ellmsha/actions/runs/38526765682). Those historical runs do not validate the new Linux desktop branch. See [release status](../../docs/release-status.md) for recorded acceptance boundaries. - -Selected macOS builds produce **Developer ID signed and notarized arm64 DMG/ZIP**; Windows produces an **unsigned x64 NSIS EXE**; Linux produces **unsigned x64 Flatpak and/or DEB**. macOS verification uses the same temporary keychain, stapled-ticket validation and Gatekeeper assessment as publication. Each desktop output includes `SHA256SUMS-.txt` and `BUILD-METADATA-.json` with exact source, selected formats and signing disposition. Full publication requires both Linux packages in addition to macOS/Windows installers. The Docker branch builds Linux/amd64 VPS images and runs disposable service smoke tests. Workflow artifacts are retained for 14 days. A successful package build does not establish native installation, account-login or host-CLI acceptance. - -Preflight uses the **release** environment when macOS or publication is selected, including release-tag runs; otherwise it uses **ci**. Selected macOS jobs use **release**, while Windows/Linux jobs use **ci**. All six Apple secrets below are required only when `macos=true`, including verification with `publish=false`; missing readiness fails before compilation. Windows-only, Linux-only and Docker-only verification need no Apple secrets. There is no unsigned macOS workflow mode or Windows signing option. Job names state each platform's signing policy and whether the run is verification or publication. Configuration support is not evidence of a completed hosted run; inspect the actual build and verification results. +See [CI.md](../../CI.md) for manual branch selection, platform switches, PR tests, artifact downloads and release-tag behavior. Every macOS job, including develop and feature branches, uses the same signing, notarization and validation as main and requires the six release-environment secrets listed below. There is no unsigned CI fallback. The workflow has no pull-request trigger. Public publication requires the official `ShiftHackZ/ellmsha` repository, an exact `vYYYY.MM.DD-patch.N` tag, matching package/About metadata and release notes at `docs/releases/YYYY.MM.DD-patch.N.md`. The version's date must equal the publication day in **Europe/Kyiv**. Manual verification runs accept a candidate's earlier date; bump the metadata before tagging if publication moves to another day. Both preflight and the final publication job check the date, root SHA and every recursive gitlink. Changed fork revisions must already be available from their public remotes. @@ -131,7 +115,7 @@ Signed macOS packaging preloads the builder's existing `graceful-fs` file-open q The reusable [Docker workflow](../../.github/workflows/docker-images.yml) only builds and smoke-tests images; it never pushes to GHCR. For full publication only, it exports the tested image IDs as a Docker tar artifact from the same workflow run, with the image manifest. Verification runs do not transfer this tar artifact. The final publication job waits for all three desktop platforms and the Docker branch to succeed, loads the image archive, and validates installer checksums plus exact tested image IDs, Linux/amd64 platform and expected OCI labels against the release version and root SHA. It then pushes immutable version/source image candidates, records their registry digests, and publishes the GitHub release. An interrupted draft can be resumed only for the same tag and exact source; an already published release is never overwritten. Publication and image promotion are serialized across tags, and a release older than the current stable `latest` is rejected. Only after publication are the manifest's exact image digests promoted to `latest`. A failed later stage can leave candidate image tags or an unpublished draft for inspection; neither is silently deleted. -Caches contain dependency downloads and Rust output. Flatpak builds additionally cache their isolated per-user Flatpak installation under the runner's temporary directory, keyed by target and the configured runtime inputs; restored runtimes are updated before packaging. This cache is separate from compiler/dependency caches and contains no application profile. DEB-only runs skip Flatpak preparation and its cache. A non-cancelled packaging failure still saves eligible cache paths, so a later retry can reuse successful compilation and downloads; signing material, app bundles and installers are excluded. Desktop Node packaging has a 4096 MiB old-space limit, inherited by the Chrome MCP Rollup subprocess; Rust and Node packaging run sequentially per runner. Builds use native hosts and locked dependencies, and action versions are pinned to verified upstream commit SHAs. Public assets are an explicit allowlist of full installers, checksums and provenance. In-app updates download/open the full installer; this workflow does not produce Squirrel packages, delta packages or update YAML. +Caches contain dependency downloads and Rust output. Flatpak builds additionally cache their isolated per-user Flatpak installation under the runner's temporary directory, keyed by target and the configured runtime inputs; restored runtimes are updated before packaging. This cache is separate from compiler/dependency caches and contains no application profile. Runs without Flatpak skip Flatpak preparation and its cache. A non-cancelled packaging failure still saves eligible cache paths, so a later retry can reuse successful compilation and downloads; signing material, app bundles and installers are excluded. Desktop Node packaging has a 4096 MiB old-space limit, inherited by the Chrome MCP Rollup subprocess; Rust and Node packaging run sequentially per runner. Builds use native hosts and locked dependencies, and action versions are pinned to verified upstream commit SHAs. Public assets are an explicit allowlist of full installers, checksums and provenance. In-app updates download/open the full installer; this workflow does not produce Squirrel packages, delta packages or update YAML. Validation commands (no build or publication): @@ -142,7 +126,7 @@ actionlint .github/workflows/release.yml .github/workflows/docker-images.yml References: [GitHub macOS certificate handling](https://docs.github.com/en/actions/how-tos/deploy/deploy-to-third-party-platforms/sign-xcode-applications), [runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), [macOS arm64 image toolchains](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md), and [GHCR authentication/visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). New GHCR packages may initially be private; confirm each package's public visibility before advertising anonymous VPS pulls. -The artifact verifier requires **every selected** format for the current version and host architecture, then writes a SHA-256 manifest beside the packages. Linux verification may explicitly select Flatpak or DEB alone; full publication requires both, catching a missing Flatpak even if DEB succeeded. Staging accepts only the selected allowlisted files and records their source/hash/size metadata. Checksums verify downloaded bytes; they do not replace signing or installation tests. CI also probes the real npm launcher on each host before building, including Windows' `npm.cmd` entry point through Node. +The artifact verifier requires **every selected** format for the current version and host architecture, then writes a SHA-256 manifest beside the packages. Linux verification may explicitly select any subset of Flatpak, DEB and AppImage; full publication requires all three, catching a missing Flatpak even if DEB succeeded. Staging accepts only the selected allowlisted files and records their source/hash/size metadata. Checksums verify downloaded bytes; they do not replace signing or installation tests. CI also probes the real npm launcher on each host before building, including Windows' `npm.cmd` entry point through Node. Linux builds that include Flatpak check the configured runtime, SDK and base, then launch the SDK sandbox before compiling. Missing tools/runtimes or denied namespaces fail with the original diagnostic; the pipeline does not disable the host's sandbox protections. DEB-only builds do not require Flatpak. The Linux desktop branch still requires hosted package-creation acceptance; desktop runtime checks below remain separate. diff --git a/scripts/release/build.mjs b/scripts/release/build.mjs index 0d356e1..8d3d90a 100644 --- a/scripts/release/build.mjs +++ b/scripts/release/build.mjs @@ -20,7 +20,8 @@ const signing = process.platform === 'darwin' : null; const signed = process.platform === 'darwin' ? Boolean(signing) : args.has('--signed'); const linuxPackages = resolveLinuxPackages(process.env.RELEASE_LINUX_PACKAGES); -const targets = { darwin: ['--mac', 'dmg', 'zip'], win32: ['--win', 'nsis'], linux: ['--linux', ...linuxPackages] }; +const hostLinuxTargets = linuxPackages.filter(format => format !== 'AppImage'); +const targets = { darwin: ['--mac', 'dmg', 'zip'], win32: ['--win', 'nsis'], linux: ['--linux', ...(hostLinuxTargets.length ? hostLinuxTargets : ['dir'])] }; // FPM requires a package URL; the root release owns the fork's homepage. const packagingArgs = process.platform === 'linux' && linuxPackages.includes('deb') ? ['--config.extraMetadata.homepage=https://github.com/ShiftHackZ/ellmsha'] @@ -75,6 +76,9 @@ const steps = [ ['cargo', ['build', '--locked', '--release', '-p', 'aionui-app', '--bin', 'aioncore'], 'aioncore'], [process.execPath, ['scripts/build-with-builder.js', process.arch, ...targets[process.platform], `--${process.arch}`, ...packagingArgs, ...signingArgs], 'aionui'], ]; +if (process.platform === 'linux' && linuxPackages.includes('AppImage')) { + steps.push(['bash', ['scripts/release/package-appimage.sh', process.arch], '.']); +} if (args.has('--plan')) { console.log(JSON.stringify({ target: `${process.platform}-${process.arch}`, signed, signingType: signing?.type, publish: false, steps }, null, 2)); } else { diff --git a/scripts/release/ci-artifacts.test.mjs b/scripts/release/ci-artifacts.test.mjs index 761ca74..9fa20e4 100644 --- a/scripts/release/ci-artifacts.test.mjs +++ b/scripts/release/ci-artifacts.test.mjs @@ -14,7 +14,7 @@ test('release requires all three platform installers, exact hashes and source pr const output = path.join(temp, 'staged'); try { await writeFile(sourceFile, JSON.stringify(source)); - for (const [target, extensions] of Object.entries({ 'mac-arm64': ['dmg', 'zip'], 'win-x64': ['exe'], 'linux-x64': ['flatpak', 'deb'] })) { + for (const [target, extensions] of Object.entries({ 'mac-arm64': ['dmg', 'zip'], 'win-x64': ['exe'], 'linux-x64': ['flatpak', 'deb', 'AppImage'] })) { const entries = []; for (const extension of extensions) { const name = `eLLMsha-${source.version}-${target}.${extension}`; @@ -42,7 +42,7 @@ test('release requires all three platform installers, exact hashes and source pr assert.equal(signed.publish, false); assert.equal(signed.signMacos, true); assert.equal((await verifyReleaseArtifacts({ directory: output, source: verification })).length, 3); - // Old/malformed provenance cannot opt out of the fixed macOS signing contract. + // Development manifests cannot opt out of the same macOS acceptance policy. const unsigned = { ...verification, signMacos: false }; await writeFile(sourceFile, JSON.stringify(unsigned)); await assert.rejects(() => stageArtifacts({ sourceFile, directory: temp, output, target: 'mac-arm64', signing: 'unsigned' }), /signed and notarized/); @@ -59,14 +59,14 @@ test('release requires all three platform installers, exact hashes and source pr }); -for (const [platform, target, extensions] of [['darwin', 'mac-arm64', ['dmg', 'zip']], ['win32', 'win-x64', ['exe']], ['linux', 'linux-x64', ['flatpak', 'deb']]]) { +for (const [platform, target, extensions] of [['darwin', 'mac-arm64', ['dmg', 'zip']], ['win32', 'win-x64', ['exe']], ['linux', 'linux-x64', ['flatpak', 'deb', 'AppImage']]]) { test(`zero-padded release dates preserve ${target} installer bytes and public names`, async () => { const temp = await mkdtemp(path.join(os.tmpdir(), 'ellmsha-date-artifacts-')); const version = '2026.01.02-patch.3'; const data = Buffer.from('synthetic signed installer bytes'); try { for (const extension of extensions) { - const nativeTarget = platform === 'linux' ? `linux-${extension === 'flatpak' ? 'x86_64' : 'amd64'}` : target; + const nativeTarget = platform === 'linux' ? `linux-${{ flatpak: 'x86_64', deb: 'amd64', AppImage: 'x64' }[extension]}` : target; await writeFile(path.join(temp, `eLLMsha-2026.1.2-patch.3-${nativeTarget}.${extension}`), data); } await verifyArtifacts({ directory: temp, version, platform, arch: target.endsWith('arm64') ? 'arm64' : 'x64' }); @@ -94,15 +94,17 @@ test('date normalization never accepts a different patch or overwrites an existi } finally { await rm(temp, { recursive: true, force: true }); } }); -for (const linuxPackages of ['flatpak', 'deb']) { +for (const linuxPackages of ['flatpak', 'deb', 'appimage']) { test(`Linux ${linuxPackages}-only verification stages exact selected bytes without requiring other formats`, async () => { const temp = await mkdtemp(path.join(os.tmpdir(), 'ellmsha-linux-artifact-')); const source = { version: '2026.11.04-patch.1', publish: false, linuxPackages, source: { commit: 'a'.repeat(40), submodules: [] } }; const sourceFile = path.join(temp, 'source.json'); const output = path.join(temp, 'staged'); const data = Buffer.from(`synthetic ${linuxPackages} bytes`); - const nativeName = `eLLMsha-2026.11.4-patch.1-linux-${linuxPackages === 'flatpak' ? 'x86_64' : 'amd64'}.${linuxPackages}`; - const publicName = `eLLMsha-${source.version}-linux-x64.${linuxPackages}`; + const extension = linuxPackages === 'appimage' ? 'AppImage' : linuxPackages; + const publicName = `eLLMsha-${source.version}-linux-x64.${extension}`; + const nativeName = linuxPackages === 'appimage' ? publicName : + `eLLMsha-2026.11.4-patch.1-linux-${linuxPackages === 'flatpak' ? 'x86_64' : 'amd64'}.${extension}`; try { await writeFile(sourceFile, JSON.stringify(source)); await writeFile(path.join(temp, nativeName), data); diff --git a/scripts/release/ci-preflight.mjs b/scripts/release/ci-preflight.mjs index adec7a4..96aa7d4 100644 --- a/scripts/release/ci-preflight.mjs +++ b/scripts/release/ci-preflight.mjs @@ -28,19 +28,26 @@ export function resolveReleaseMode(env) { }; const macos = env.GITHUB_EVENT_NAME === 'push' || selected(env.RELEASE_BUILD_MACOS); const windows = env.GITHUB_EVENT_NAME === 'push' || selected(env.RELEASE_BUILD_WINDOWS); - const linux = env.GITHUB_EVENT_NAME === 'push' || selected(env.RELEASE_BUILD_LINUX); + let linux = env.GITHUB_EVENT_NAME === 'push' || selected(env.RELEASE_BUILD_LINUX); const docker = env.GITHUB_EVENT_NAME === 'push' || selected(env.RELEASE_BUILD_DOCKER); - const linuxPackages = env.GITHUB_EVENT_NAME === 'push' ? 'all' : env.RELEASE_LINUX_PACKAGES ?? 'all'; + let linuxPackages = env.GITHUB_EVENT_NAME === 'push' ? 'all' : env.RELEASE_LINUX_PACKAGES ?? 'all'; + const formats = ['flatpak', 'deb', 'appimage']; + if (env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.RELEASE_LINUX_FLATPAK !== undefined) { + const requested = formats.filter(format => selected(env[`RELEASE_LINUX_${format.toUpperCase()}`])); + linux = requested.length > 0; + linuxPackages = requested.length && requested.length < formats.length ? requested.join(',') : 'all'; + } resolveLinuxPackages(linuxPackages); + const signMacos = macos; const desktop = macos || windows || linux; if (!desktop && !docker) throw new Error('Select at least one build: macos, windows, linux or docker'); if (publish && (!macos || !windows || !linux || !docker || linuxPackages !== 'all')) { throw new Error('Publication requires all four platforms and all Linux packages from this run'); } - if (macos && env.REQUIRE_SIGNING_SECRETS === 'true' && env.MAC_SIGNING_READY !== 'true') { + if (signMacos && env.REQUIRE_SIGNING_SECRETS === 'true' && env.MAC_SIGNING_READY !== 'true') { throw new Error(`Signed macOS verification/publication requires Developer ID/notarization secrets: ${MAC_SIGNING_SECRETS.join(', ')}`); } - return { publish, signMacos: macos, macos, windows, linux, docker, desktop, linuxPackages }; + return { publish, signMacos, macos, windows, linux, docker, desktop, linuxPackages }; } export function releaseDate(now = new Date()) { diff --git a/scripts/release/ci-preflight.test.mjs b/scripts/release/ci-preflight.test.mjs index 588d09b..9075c08 100644 --- a/scripts/release/ci-preflight.test.mjs +++ b/scripts/release/ci-preflight.test.mjs @@ -22,18 +22,18 @@ test('manual publication can seed main caches but rejects other branch refs', () }); const allPlatforms = (publish) => ({ publish, signMacos: true, macos: true, windows: true, linux: true, docker: true, desktop: true, linuxPackages: 'all' }); test('all platforms default on; selected macOS stays signed and only publication requires tag/date', () => { - assert.deepEqual(resolveReleaseMode({ GITHUB_EVENT_NAME: 'workflow_dispatch' }), allPlatforms(false)); + assert.deepEqual(resolveReleaseMode({ GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch' }), allPlatforms(false)); for (const event of ['workflow_dispatch', 'push']) { for (const publish of [false, true]) { - const mode = resolveReleaseMode({ GITHUB_EVENT_NAME: event, RELEASE_PUBLISH: String(publish) }); + const mode = resolveReleaseMode({ GITHUB_REF: input.ref, GITHUB_EVENT_NAME: event, RELEASE_PUBLISH: String(publish) }); assert.deepEqual(mode, allPlatforms(event === 'push' || publish)); } } - const mode = resolveReleaseMode({ GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: 'false' }); + const mode = resolveReleaseMode({ GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: 'false' }); assert.equal(validateRelease({ ...input, ...mode, event: 'workflow_dispatch', ref: 'refs/heads/main', now: new Date('2026-11-02T12:00:00Z') }).publish, false); }); test('desktop preflight fails early without all six secrets, including non-publishing runs', () => { - const env = { GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: 'false', REQUIRE_SIGNING_SECRETS: 'true' }; + const env = { GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: 'false', REQUIRE_SIGNING_SECRETS: 'true' }; assert.throws(() => resolveReleaseMode(env), (error) => MAC_SIGNING_SECRETS.every((name) => error.message.includes(name))); assert.deepEqual(resolveReleaseMode({ ...env, MAC_SIGNING_READY: 'true' }), allPlatforms(false)); assert.throws(() => resolveReleaseMode({ ...env, RELEASE_SIGN_MACOS: 'false' }), /requires Developer ID/); @@ -44,7 +44,7 @@ test('desktop preflight fails early without all six secrets, including non-publi }); test('all sixteen platform combinations select exact jobs; publication requires the complete set', () => { for (const macos of [false, true]) for (const windows of [false, true]) for (const linux of [false, true]) for (const docker of [false, true]) for (const publish of [false, true]) { - const env = { GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: String(publish), RELEASE_BUILD_MACOS: String(macos), RELEASE_BUILD_WINDOWS: String(windows), RELEASE_BUILD_LINUX: String(linux), RELEASE_BUILD_DOCKER: String(docker), REQUIRE_SIGNING_SECRETS: 'true', MAC_SIGNING_READY: String(macos) }; + const env = { GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: String(publish), RELEASE_BUILD_MACOS: String(macos), RELEASE_BUILD_WINDOWS: String(windows), RELEASE_BUILD_LINUX: String(linux), RELEASE_BUILD_DOCKER: String(docker), REQUIRE_SIGNING_SECRETS: 'true', MAC_SIGNING_READY: String(macos) }; if (!macos && !windows && !linux && !docker) assert.throws(() => resolveReleaseMode(env), /at least one build/); else if (publish && (!macos || !windows || !linux || !docker)) assert.throws(() => resolveReleaseMode(env), /all four platforms/); else { @@ -53,18 +53,18 @@ test('all sixteen platform combinations select exact jobs; publication requires assert.deepEqual(desktopMatrix(mode).include.map(({ target }) => target), [macos && 'mac-arm64', windows && 'win-x64', linux && 'linux-x64'].filter(Boolean)); } } - assert.deepEqual(resolveReleaseMode({ GITHUB_EVENT_NAME: 'push', RELEASE_BUILD_MACOS: 'false', RELEASE_BUILD_WINDOWS: 'false', RELEASE_BUILD_LINUX: 'false', RELEASE_BUILD_DOCKER: 'false', RELEASE_LINUX_PACKAGES: 'flatpak', RELEASE_PUBLISH: 'false' }), allPlatforms(true)); - for (const key of ['RELEASE_BUILD_MACOS', 'RELEASE_BUILD_WINDOWS', 'RELEASE_BUILD_LINUX', 'RELEASE_BUILD_DOCKER']) assert.throws(() => resolveReleaseMode({ GITHUB_EVENT_NAME: 'workflow_dispatch', [key]: 'invalid' }), /true or false/); + assert.deepEqual(resolveReleaseMode({ GITHUB_REF: input.ref, GITHUB_EVENT_NAME: 'push', RELEASE_BUILD_MACOS: 'false', RELEASE_BUILD_WINDOWS: 'false', RELEASE_BUILD_LINUX: 'false', RELEASE_BUILD_DOCKER: 'false', RELEASE_LINUX_PACKAGES: 'flatpak', RELEASE_PUBLISH: 'false' }), allPlatforms(true)); + for (const key of ['RELEASE_BUILD_MACOS', 'RELEASE_BUILD_WINDOWS', 'RELEASE_BUILD_LINUX', 'RELEASE_BUILD_DOCKER']) assert.throws(() => resolveReleaseMode({ GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', [key]: 'invalid' }), /true or false/); }); test('Flatpak-only manual testing needs no Apple credentials and cannot publish partial packages', () => { - const env = { GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_BUILD_MACOS: 'false', RELEASE_BUILD_WINDOWS: 'false', RELEASE_BUILD_LINUX: 'true', RELEASE_BUILD_DOCKER: 'false', RELEASE_LINUX_PACKAGES: 'flatpak', REQUIRE_SIGNING_SECRETS: 'true' }; + const env = { GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_BUILD_MACOS: 'false', RELEASE_BUILD_WINDOWS: 'false', RELEASE_BUILD_LINUX: 'true', RELEASE_BUILD_DOCKER: 'false', RELEASE_LINUX_PACKAGES: 'flatpak', REQUIRE_SIGNING_SECRETS: 'true' }; const mode = resolveReleaseMode(env); assert.equal(mode.linuxPackages, 'flatpak'); assert.equal(mode.signMacos, false); assert.deepEqual(desktopMatrix(mode), { include: [{ os: 'ubuntu-24.04', target: 'linux-x64', signing: 'unsigned', environment: 'ci' }] }); assert.throws(() => resolveReleaseMode({ ...env, RELEASE_PUBLISH: 'true' }), /all four platforms/); - assert.throws(() => resolveReleaseMode({ GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: 'true', RELEASE_LINUX_PACKAGES: 'flatpak' }), /all Linux packages/); - assert.throws(() => resolveReleaseMode({ ...env, RELEASE_LINUX_PACKAGES: 'appimage' }), /Linux package/); + assert.throws(() => resolveReleaseMode({ GITHUB_REF: 'refs/heads/main', GITHUB_EVENT_NAME: 'workflow_dispatch', RELEASE_PUBLISH: 'true', RELEASE_LINUX_PACKAGES: 'flatpak' }), /all Linux packages/); + assert.throws(() => resolveReleaseMode({ ...env, RELEASE_LINUX_PACKAGES: 'rpm' }), /Linux package/); }); test('source provenance rejects missing, moved, conflicted or modified gitlinks', () => { const sha = 'a'.repeat(40); @@ -75,3 +75,22 @@ test('source provenance rejects missing, moved, conflicted or modified gitlinks' assert.throws(() => collectSource('/fixture', (args) => args[0] === 'status' ? ' M source.ts' : git(args)), /tracked changes/); assert.throws(() => collectSource('/fixture', (args) => args[0] === 'submodule' ? '' : git(args)), /missing/); }); + +test('all 64 manual combinations use main-equivalent signing on every branch', () => { + for (const ref of ['refs/heads/main', 'refs/heads/develop', 'refs/heads/feature/example', 'refs/heads/feature/nested/example']) { + for (let mask = 0; mask < 64; mask++) { + const values = ['BUILD_MACOS', 'BUILD_WINDOWS', 'LINUX_FLATPAK', 'LINUX_DEB', 'LINUX_APPIMAGE', 'BUILD_DOCKER']; + const env = { GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: ref, REQUIRE_SIGNING_SECRETS: 'true', MAC_SIGNING_READY: 'true' }; + values.forEach((key, bit) => { env['RELEASE_' + key] = String(Boolean(mask & (1 << bit))); }); + if (!mask) { assert.throws(() => resolveReleaseMode(env), /at least one/); continue; } + const mode = resolveReleaseMode(env); + const formats = ['flatpak', 'deb', 'appimage'].filter((_, i) => mask & (4 << i)); + assert.equal(mode.linuxPackages, formats.length === 0 || formats.length === 3 ? 'all' : formats.join(',')); + assert.equal(mode.linux, formats.length > 0); + assert.equal(mode.signMacos, Boolean(mask & 1)); + assert.ok(desktopMatrix(mode).include.every(row => row.environment === (row.target === 'mac-arm64' ? 'release' : 'ci'))); + if (mode.macos) assert.throws(() => resolveReleaseMode({ ...env, MAC_SIGNING_READY: 'false' }), /Developer ID/); + assert.equal(mode.publish, false); + } + } +}); diff --git a/scripts/release/ci-publish.test.mjs b/scripts/release/ci-publish.test.mjs index aea7d1f..5662054 100644 --- a/scripts/release/ci-publish.test.mjs +++ b/scripts/release/ci-publish.test.mjs @@ -14,7 +14,7 @@ test('publication resumes only an exact-source draft and never overwrites a publ const sourceFile = path.join(temp, 'source.json'), directory = path.join(temp, 'staged'); try { await writeFile(sourceFile, JSON.stringify(source)); - for (const [target, extensions] of Object.entries({ 'mac-arm64': ['dmg', 'zip'], 'win-x64': ['exe'], 'linux-x64': ['flatpak', 'deb'] })) { + for (const [target, extensions] of Object.entries({ 'mac-arm64': ['dmg', 'zip'], 'win-x64': ['exe'], 'linux-x64': ['flatpak', 'deb', 'AppImage'] })) { const sums = []; for (const extension of extensions) { const name = `eLLMsha-${source.version}-${target}.${extension}`, content = `fixture ${name}`; @@ -44,8 +44,8 @@ test('publication resumes only an exact-source draft and never overwrites a publ assert.ok(calls[2].includes('--draft')); assert.ok(calls[2].includes(source.source.commit)); const uploaded = calls[2].filter((value) => value.startsWith(`${directory}${path.sep}`)); - assert.equal(uploaded.length, 12); - for (const name of [`eLLMsha-${source.version}-linux-x64.flatpak`, `eLLMsha-${source.version}-linux-x64.deb`, 'SHA256SUMS-linux-x64.txt', 'BUILD-METADATA-linux-x64.json']) assert.ok(uploaded.includes(path.join(directory, name))); + assert.equal(uploaded.length, 13); + for (const name of [`eLLMsha-${source.version}-linux-x64.flatpak`, `eLLMsha-${source.version}-linux-x64.deb`, `eLLMsha-${source.version}-linux-x64.AppImage`, 'SHA256SUMS-linux-x64.txt', 'BUILD-METADATA-linux-x64.json']) assert.ok(uploaded.includes(path.join(directory, name))); assert.ok(!calls.flat().some((value) => value.includes('unrelated-private-file'))); existing = { draft: true, tag_name: `v${source.version}`, target_commitish: source.source.commit, assets: [] }; calls.length = 0; diff --git a/scripts/release/ci-workflow.test.mjs b/scripts/release/ci-workflow.test.mjs index 0a60021..3fe8e59 100644 --- a/scripts/release/ci-workflow.test.mjs +++ b/scripts/release/ci-workflow.test.mjs @@ -41,20 +41,21 @@ test('desktop packaging has a bounded heap inherited by its Node subprocesses', test('signed verification wires the same signing lifecycle without enabling publication', () => { assert.doesNotMatch(workflow, /sign_macos|WINDOWS_SIGNING_ENABLED|WINDOWS_CERTIFICATE|WIN_CSC|Authenticode/); - assert.match(workflow, /preflight:\n\s+runs-on: ubuntu-24.04\n\s+environment: \$\{\{ \(github.event_name == 'push' \|\| inputs.macos \|\| inputs.publish\) && 'release' \|\| 'ci' \}\}/); + assert.ok(workflow.includes("environment: ${{ (github.event_name == 'push' || inputs.macos) && 'release' || 'ci' }}")); + assert.doesNotMatch(workflow, /Build unsigned macOS|signed on main and release tags/); assert.match(workflow, /REQUIRE_SIGNING_SECRETS: 'true'/); const readiness = workflow.match(/MAC_SIGNING_READY: (.+)/)[1]; for (const secret of ['MAC_CERTIFICATE_P12_BASE64', 'MAC_CERTIFICATE_PASSWORD', 'MAC_SIGNING_IDENTITY', 'MAC_TEAM_ID', 'MAC_NOTARY_APPLE_ID', 'MAC_NOTARY_APP_PASSWORD']) assert.ok(readiness.includes(`secrets.${secret} != ''`)); assert.match(workflow, /matrix: \$\{\{ fromJSON\(needs.preflight.outputs.desktop_matrix\) \}\}/); assert.match(workflow, /name: \$\{\{ matrix.target \}\} \(\$\{\{ matrix.signing \}\}; \$\{\{ needs.preflight.outputs.publish == 'true' && 'release' \|\| 'verification' \}\}\)/); - assert.match(workflow, /Prepare ephemeral Developer ID keychain and notarization\n\s+if: runner.os == 'macOS'\n/); + assert.match(workflow, /Prepare ephemeral Developer ID keychain and notarization\n\s+if: runner.os == 'macOS' && matrix.environment == 'release'\n/); const macBuild = workflow.split('name: Build macOS installers\n')[1].split('\n - ')[0]; assert.match(macBuild, /node scripts\/release\/build.mjs --signed\n\s+node scripts\/release\/ci-macos-signing.mjs validate[^\n]+\n\s+spctl --assess/); assert.doesNotMatch(macBuild, /publish|--unsigned/); const winBuild = workflow.split('name: Build Windows x86_64 installer\n')[1].split('\n - ')[0]; assert.match(winBuild, /node scripts\/release\/build.mjs --unsigned/); assert.doesNotMatch(winBuild, /secrets\.|--signed/); - assert.match(workflow, /Remove temporary signing material\n\s+if: always\(\) && runner.os == 'macOS'\n/); + assert.match(workflow, /Remove temporary signing material\n\s+if: always\(\) && runner.os == 'macOS' && matrix.environment == 'release'\n/); const images = workflow.split('\n images:\n')[1].split('\n publish:\n')[0]; assert.match(images, /publish: \$\{\{ needs.preflight.outputs.publish == 'true' \}\}/); }); @@ -71,15 +72,17 @@ test('failed packaging retains the same non-secret cache paths without saving ca }); test('macOS, Windows, Linux and Docker have independent default-on dispatch flags', () => { - for (const flag of ['macos', 'windows', 'linux', 'docker']) { + for (const flag of ['macos', 'windows', 'docker']) { assert.match(workflow, new RegExp(` ${flag}:\\n description: [^\\n]+\\n type: boolean\\n default: true`)); assert.ok(workflow.includes(`RELEASE_BUILD_${flag.toUpperCase()}: \${{ github.event_name == 'push' || inputs.${flag} }}`)); assert.ok(workflow.includes(`${flag}: \${{ steps.source.outputs.${flag} }}`)); } assert.match(workflow, /desktop:\n needs: preflight\n if: needs\.preflight\.outputs\.desktop == 'true'/); assert.match(workflow, /desktop_matrix: \$\{\{ steps.source.outputs.desktop_matrix \}\}/); - assert.match(workflow, /linux_packages:\n description: [^\n]+\n type: choice\n options: \[all, flatpak, deb\]\n default: all/); - assert.match(workflow, /RELEASE_LINUX_PACKAGES: \$\{\{ github.event_name == 'push' && 'all' \|\| inputs.linux_packages \}\}/); + for (const format of ['flatpak', 'deb', 'appimage']) { + assert.match(workflow, new RegExp(` linux-${format}:\\n description: [^\\n]+\\n type: boolean\\n default: true`)); + assert.ok(workflow.includes(`RELEASE_LINUX_${format.toUpperCase()}: \${{ github.event_name == 'push' || inputs['linux-${format}'] }}`)); + } assert.match(workflow, /RELEASE_LINUX_PACKAGES: \$\{\{ needs.preflight.outputs.linux_packages \}\}/); assert.match(workflow, /images:\n needs: preflight\n if: needs\.preflight\.outputs\.docker == 'true'/); assert.match(workflow, /publish:\n needs: \[preflight, desktop, images\]\n if: needs\.preflight\.outputs\.publish == 'true'/); @@ -96,13 +99,13 @@ test('Linux installs native tools and configured Flatpak SDKs before compiling; const linuxDeps = workflow.split('name: Install Linux native build dependencies\n')[1].split('\n - ')[0]; assert.match(linuxDeps, /if: runner.os == 'Linux'/); assert.match(linuxDeps, /build-essential cmake pkg-config libssl-dev/); - assert.match(linuxDeps, /if \[ "\$RELEASE_LINUX_PACKAGES" != deb \]/); + assert.match(linuxDeps, /if \[\[ "\$RELEASE_LINUX_PACKAGES" == all \|\| "\$RELEASE_LINUX_PACKAGES" == \*flatpak\* \]\]/); assert.match(linuxDeps, /flatpak flatpak-builder dbus-x11/); assert.match(workflow, /uses: actions\/setup-go@[^\n]+\n if: runner.os != 'macOS'/); const order = ['id: flatpak-cache', 'name: Install Linux native build dependencies', 'name: Install locked dependencies', 'name: Prepare configured Flatpak runtimes and verify sandbox', 'name: Build Linux x86_64 desktop packages']; let previous = -1; for (const name of order) { const current = workflow.indexOf(name); assert.ok(current > previous, name); previous = current; } - assert.match(workflow, /Prepare configured Flatpak runtimes and verify sandbox\n if: runner.os == 'Linux' && needs.preflight.outputs.linux_packages != 'deb'\n run: dbus-run-session -- node scripts\/release\/flatpak-preflight.mjs --install/); + assert.match(workflow, /Prepare configured Flatpak runtimes and verify sandbox\n if: runner.os == 'Linux' && \(needs.preflight.outputs.linux_packages == 'all' \|\| contains\(needs.preflight.outputs.linux_packages, 'flatpak'\)\)\n run: dbus-run-session -- node scripts\/release\/flatpak-preflight.mjs --install/); assert.match(workflow, /FLATPAK_USER_DIR:'flatpak-user'/); const restore = workflow.split('id: flatpak-cache\n')[1].split('\n - ')[0]; const save = workflow.split('name: Retain Flatpak runtime cache after packaging failure\n')[1].split('\n - ')[0]; @@ -112,7 +115,7 @@ test('Linux installs native tools and configured Flatpak SDKs before compiling; assert.match(save, /key: \$\{\{ steps.flatpak-cache.outputs.cache-primary-key \}\}/); const build = workflow.split('name: Build Linux x86_64 desktop packages\n')[1].split('\n - ')[0]; assert.match(build, /node scripts\/release\/build.mjs --unsigned/); - assert.match(build, /if \[ "\$RELEASE_LINUX_PACKAGES" = deb \]; then\n\s+node scripts\/release\/build.mjs --unsigned\n\s+else\n\s+dbus-run-session -- node scripts\/release\/build.mjs --unsigned/); + assert.match(build, /if \[\[ "\$RELEASE_LINUX_PACKAGES" != all && "\$RELEASE_LINUX_PACKAGES" != \*flatpak\* \]\]; then\n\s+node scripts\/release\/build.mjs --unsigned\n\s+else\n\s+dbus-run-session -- node scripts\/release\/build.mjs --unsigned/); }); test('parallel image builds cannot publish; tested bytes transfer only for gated full publication', () => { diff --git a/scripts/release/flatpak-preflight.mjs b/scripts/release/flatpak-preflight.mjs index fb516d7..6aa64e6 100644 --- a/scripts/release/flatpak-preflight.mjs +++ b/scripts/release/flatpak-preflight.mjs @@ -5,9 +5,14 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; export function resolveLinuxPackages(value = 'all') { - if (value === 'all') return ['flatpak', 'deb']; - if (value === 'flatpak' || value === 'deb') return [value]; - throw new Error(`Linux package selection must be all, flatpak or deb; got ${JSON.stringify(value)}`); + const formats = { flatpak: 'flatpak', deb: 'deb', appimage: 'AppImage' }; + if (value === 'all') return Object.values(formats); + const selected = typeof value === 'string' ? value.split(',') : []; + if (!selected.length || new Set(selected).size !== selected.length || + selected.some(format => !Object.hasOwn(formats, format))) { + throw new Error(`Linux package selection must be all or a list of flatpak, deb, appimage; got ${JSON.stringify(value)}`); + } + return selected.map(format => formats[format]); } function configuredRefs(config) { diff --git a/scripts/release/flatpak-preflight.test.mjs b/scripts/release/flatpak-preflight.test.mjs index 8794293..593720e 100644 --- a/scripts/release/flatpak-preflight.test.mjs +++ b/scripts/release/flatpak-preflight.test.mjs @@ -7,17 +7,17 @@ import { checkFlatpak, installFlatpak, resolveLinuxPackages } from './flatpak-pr const config = { runtime: 'org.freedesktop.Platform', sdk: 'org.freedesktop.Sdk', runtimeVersion: '26.08', base: 'org.electronjs.Electron2.BaseApp', baseVersion: '26.08' }; test('Linux package selection is explicit and rejects unsupported formats', () => { - assert.deepEqual(resolveLinuxPackages(), ['flatpak', 'deb']); - assert.deepEqual(resolveLinuxPackages('all'), ['flatpak', 'deb']); + assert.deepEqual(resolveLinuxPackages(), ['flatpak', 'deb', 'AppImage']); + assert.deepEqual(resolveLinuxPackages('all'), ['flatpak', 'deb', 'AppImage']); assert.deepEqual(resolveLinuxPackages('flatpak'), ['flatpak']); assert.deepEqual(resolveLinuxPackages('deb'), ['deb']); - for (const value of ['', 'appimage', 'flatpak,deb', '--publish=always', null]) { + for (const value of ['', 'rpm', 'deb,deb', '--publish=always', null]) { assert.throws(() => resolveLinuxPackages(value), /Linux package selection/); } }); test('native Linux build plans forward only the selected package formats', () => { - for (const selection of ['all', 'flatpak', 'deb']) { + for (const selection of ['all', 'flatpak', 'deb', 'appimage', 'deb,appimage', 'flatpak,appimage']) { // Planning does not execute a build or sandbox; model the native Linux host // so this contract also runs in macOS and Windows release preflight. const script = new URL('./build.mjs', import.meta.url); @@ -31,8 +31,12 @@ test('native Linux build plans forward only the selected package formats', () => const plan = JSON.parse(result.stdout); assert.equal(plan.target, 'linux-x64'); assert.equal(plan.publish, false); - const metadata = selection === 'flatpak' ? [] : ['--config.extraMetadata.homepage=https://github.com/ShiftHackZ/ellmsha']; - assert.deepEqual(plan.steps[1][1], ['scripts/build-with-builder.js', 'x64', '--linux', ...resolveLinuxPackages(selection), '--x64', ...metadata]); + const targets = resolveLinuxPackages(selection); + const host = targets.filter(value => value !== 'AppImage'); + const metadata = !targets.includes('deb') ? [] : ['--config.extraMetadata.homepage=https://github.com/ShiftHackZ/ellmsha']; + assert.deepEqual(plan.steps[1][1], ['scripts/build-with-builder.js', 'x64', '--linux', ...(host.length ? host : ['dir']), '--x64', ...metadata]); + assert.equal(plan.steps.length, targets.includes('AppImage') ? 3 : 2); + if (targets.includes('AppImage')) assert.deepEqual(plan.steps[2], ['bash', ['scripts/release/package-appimage.sh', 'x64'], '.']); } }); diff --git a/scripts/release/package-appimage.sh b/scripts/release/package-appimage.sh new file mode 100644 index 0000000..b1bb5db --- /dev/null +++ b/scripts/release/package-appimage.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Wrap the Linux payload already produced by electron-builder. This container +# needs neither FUSE privileges nor access to the Docker socket or user profile. +set -euo pipefail +arch=$1 +case "$arch" in + x64) platform=linux/amd64; unpacked=linux-unpacked ;; + arm64) platform=linux/arm64; unpacked=linux-arm64-unpacked ;; + *) echo "Unsupported AppImage architecture: $arch" >&2; exit 1 ;; +esac +ui="$PWD/aionui" +test -x "$ui/out/$unpacked/eLLMsha" +cache="${ELECTRON_BUILDER_CACHE:-$PWD/.data/electron-builder-cache}" +mkdir -p "$cache" +version=$(node -p "require('./aionui/package.json').version") +filename="eLLMsha-$version-linux-$arch.AppImage" +docker run --rm --platform "$platform" --user "$(id -u):$(id -g)" \ + --env HOME=/tmp --env ELECTRON_CACHE=/tmp/electron --env ELECTRON_BUILDER_CACHE=/cache \ + --volume "$PWD/scripts:/repo/scripts:ro" --volume "$ui:/repo/aionui:ro" \ + --volume "$ui/out:/repo/aionui/out" --volume "$cache:/cache" \ + --workdir /repo/aionui node:24.11.0-bookworm@sha256:e5bbac0e9b8a6e3b96a86a82bbbcf4c533a879694fd613ed616bae5116f6f243 \ + node node_modules/electron-builder/cli.js --config packages/desktop/electron-builder.yml \ + --linux AppImage "--$arch" --prepackaged "out/$unpacked" --publish=never \ + "--config.appImage.artifactName=$filename" + +# Exercise the self-extractor without FUSE or starting the desktop app. +image="$ui/out/$filename" +test -x "$image" +check=$(mktemp -d) +trap 'rm -rf -- "$check"' EXIT +cd "$check" +"$image" --appimage-extract '*.desktop' +grep -q '^Name=eLLMsha$' squashfs-root/*.desktop diff --git a/scripts/release/secrets-plugin.test.mjs b/scripts/release/secrets-plugin.test.mjs new file mode 100644 index 0000000..290628f --- /dev/null +++ b/scripts/release/secrets-plugin.test.mjs @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, statSync } from 'node:fs'; +const root = new URL('../../', import.meta.url); +test('bundled Secrets plugin references its real scoped skill without external permissions', () => { + const manifest = JSON.parse(readFileSync(new URL('plugins/secrets/aion-extension.json', root))); + assert.equal(manifest.name, 'secrets'); + assert.equal(manifest.permissions.shell, false); + assert.deepEqual(manifest.permissions.network.allowedDomains, []); + assert.equal(manifest.permissions.active_user, true); + assert.equal(manifest.contributes.skills.length, 1); + const skillPath = manifest.contributes.skills[0].path; + assert.equal(skillPath, 'skills/secrets/SKILL.md'); + const skill = readFileSync(new URL(`plugins/secrets/${skillPath}`, root), 'utf8'); + for (const name of ['ellmsha_global_secrets_list', 'ellmsha_project_secrets_list', 'ellmsha_ask_user']) assert.ok(skill.includes(name)); + assert.match(skill, /Standard project/); + assert.match(skill, /Never save codes, TOTP seeds, recovery material or passkey private keys/); + assert.match(skill, /Masking does not erase/); + assert.ok(statSync(new URL('plugins/secrets/README.md', root)).isFile()); +}); diff --git a/tencentdb-agent-memory b/tencentdb-agent-memory index cb818ae..10ab2a9 160000 --- a/tencentdb-agent-memory +++ b/tencentdb-agent-memory @@ -1 +1 @@ -Subproject commit cb818ae2de2723d76460f52210e829f9b999907c +Subproject commit 10ab2a92e6e86aefbe95d5e7d88e39cb1d3ab79b