Skip to content
This repository was archived by the owner on Oct 30, 2026. It is now read-only.
Closed
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
49 changes: 43 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ on:
hardware_tag:
description: Hardware version to pair with this CLI
required: true
default: v0.1.10
default: v0.1.11
hardware_repository:
description: Repository providing the paired hardware release or workflow artifact
required: true
default: OrchardLinux/hardware
hardware_run_id:
description: Optional hardware workflow run providing the tested wheel before its first release
required: false
Expand All @@ -24,31 +28,62 @@ jobs:
package:
runs-on: ubuntu-24.04
env:
HARDWARE_TAG: ${{ inputs.hardware_tag || 'v0.1.10' }}
HARDWARE_REPOSITORY: ${{ inputs.hardware_repository || 'OrchardLinux/hardware' }}
HARDWARE_TAG: ${{ inputs.hardware_tag || 'v0.1.11' }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Read the reviewed PR hardware dependency
if: github.event_name == 'pull_request'
id: hardware-test
run: |
python - <<'PYTHON'
import json, os, re
from pathlib import Path
pin = json.loads(Path('ci/hardware-test.json').read_text())
assert re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', pin['repository'])
assert re.fullmatch(r'[0-9a-f]{40}', pin['ref'])
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write('repository=' + pin['repository'] + '\nref=' + pin['ref'] + '\n')
PYTHON
- name: Check out the exact hardware candidate for PR tests
if: github.event_name == 'pull_request'
uses: actions/checkout@v4
with:
repository: ${{ steps.hardware-test.outputs.repository }}
ref: ${{ steps.hardware-test.outputs.ref }}
path: out/hardware-source
persist-credentials: false
- name: Build the pinned hardware candidate wheel
if: github.event_name == 'pull_request'
env:
HARDWARE_REF: ${{ steps.hardware-test.outputs.ref }}
run: |
test "$(git -C out/hardware-source rev-parse HEAD)" = "$HARDWARE_REF"
python -m pip install build
python -m build --wheel --outdir "$GITHUB_WORKSPACE/out/hardware" out/hardware-source
- name: Download pinned released hardware dependency
if: inputs.hardware_run_id == ''
if: github.event_name != 'pull_request' && inputs.hardware_run_id == ''
env:
GH_TOKEN: ${{ github.token }}
run: |
[[ "$HARDWARE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]
[[ "$HARDWARE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
mkdir -p out/hardware
gh release download "$HARDWARE_TAG" --repo OrchardLinux/hardware \
gh release download "$HARDWARE_TAG" --repo "$HARDWARE_REPOSITORY" \
--pattern '*.whl' --pattern SHA256SUMS --dir out/hardware
(cd out/hardware && sha256sum --check --ignore-missing SHA256SUMS)
- name: Reuse the already-built hardware wheel during first-release bring-up
if: inputs.hardware_run_id != ''
if: github.event_name != 'pull_request' && inputs.hardware_run_id != ''
uses: actions/download-artifact@v4
with:
name: hardware-packages
path: out/hardware
repository: OrchardLinux/hardware
repository: ${{ env.HARDWARE_REPOSITORY }}
run-id: ${{ inputs.hardware_run_id }}
github-token: ${{ github.token }}
- name: Reuse the unchanged CLI distribution
Expand All @@ -72,6 +107,7 @@ jobs:
python -c 'import os, importlib.metadata as m; assert os.environ["HARDWARE_TAG"] == "v" + m.version("orchard-hardware")'
mkdir -p out/pair
cp dist/*.whl dist/*.tar.gz out/hardware/*.whl out/pair/
if [[ '${{ github.event_name }}' == 'pull_request' ]]; then cp ci/hardware-test.json out/pair/; fi
- name: Check tag matches package version
if: github.ref_type == 'tag'
env:
Expand Down Expand Up @@ -102,6 +138,7 @@ jobs:
path: out/pair
- run: python -m pip install out/pair/*.whl && python -m pip check
- run: python -m unittest discover -s tests -v
- run: python -m pip install mypy==1.18.2 && python -m mypy
assets:
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
needs: test
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ orchard install omarchy-a

The registered name stores a URL. Each resolution fetches the current index; verified images are cached by SHA-256. Publishing a new distro index updates future installations, not an already installed operating system.

## Reset an existing filesystem

During installation, compatible recovery offers **Keep filesystem** (default),
**Reset filesystem**, or **Cancel** after you select an existing ext4 Linux-data
partition. Reset removes everything on that partition, including other installation
directories, personal files and cached images. It creates no backup, keeps the
partition layout unchanged, and is not secure erasure. Confirm by typing the exact
`reset <partition ID>` phrase shown by the installer. Installation has its own
confirmation after reset succeeds.

Older recovery images keep the existing installation workflow without a reset
option. Reset requires rebuilding installer recovery with the supporting hardware
runtime; updating the host CLI alone is insufficient. If reset cannot be verified,
installation stops: do not retry reset automatically. Inspect storage and the saved
boot-scoped `last-format.json` report first.

## Launch an installed system

Connect the device in DFU mode, then run:
Expand Down
4 changes: 4 additions & 0 deletions ci/hardware-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"repository": "stuart902/hardware",
"ref": "d6116dc0ca996782275ebc26502f206a5d1cccdb"
}
45 changes: 44 additions & 1 deletion docs/device-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ All records include `protocol: 1`. Lost connections during mutations mean unknow
| Operation | Request | Result / effect |
|---|---|---|
| `bootstrap` | public key, profile, ECID, boot ID (serial) | Authorize in RAM, ensure the bundled SSH daemon, return host key and endpoint. No hardware preparation or storage access. |
| `status` | none | Profile, live ECID/boot ID, release, phase/failure state, desktop readiness, device-owned `installation_attempted` flag, formatting receipt and capabilities. |
| `status` | none | Profile, live ECID/boot ID, release, phase/failure state, desktop readiness, device-owned `installation_attempted` flag, formatting receipt and capabilities (including `reset-ext4-v1` in supporting installer recovery). |
| `prepare` | release, bytes, sha256 + mixin stdin | Verify fresh-ADT binding/data allowlist; apply in RAM, prepare hardware once, and clean owned leftover staging before target selection. |
| `targets` | none | Boot ID, device-issued selection token, capacity estimate and GPT Linux-data partition/signature descriptions; free space for verified ext4. |
| `format-target` | token, target ID, boot ID, operation ID, explicit confirmation | Once per recovery boot after preparation: revalidate a Linux-data target without recognized signatures, create ext4 without force/discard, verify UUID and restore fencing. Return a matching receipt; invalidate the old selection. |
| `reset-target` | token, target ID, boot ID, operation ID, expected filesystem UUID, exact `reset <target ID>` confirmation | Capability `reset-ext4-v1` required. Reset a verified existing ext4 Linux-data partition; preserve GPT and partition geometry, assign a new UUID, verify emptiness and restore fencing. Shares the once-per-boot permission with `format-target`; return a matching receipt and invalidate the selection. |
| `destination` | token, target ID, relative directory | Read-only inspection; return existence, destination identity, free/reclaimable bytes, estimated needed bytes and inode sufficiency. No ownership is inferred from installation markers. |
| `install` | selected target/directory, destination_identity, explicit replace boolean, bytes, sha256 + root stdin | Revalidate selection; stream into fresh staging, verify, apply mixin and promote. |
| `handoff` | none | Recheck the completed installation, preserve diagnostics and ask recovery PID 1 to switch root. |
Expand Down Expand Up @@ -62,3 +63,45 @@ Host code must not infer device paths or encode module loads, filesystem offsets
- Pre-exec PID 1 failures attempt to restore moved pseudo-filesystem aliases and restart key-only recovery diagnostics, never retrying handoff/storage. Reported failures stop host readiness polling immediately. Kernel failure or failure inside the final root-switch executable can still require DFU recovery.
- The desktop shell reports readiness only after constructing its root and successfully capturing a compositor frame to `/dev/null`. The device checks the boot ID, live shell process and owned Wayland socket.
- No screenshot is retained. Readiness does not attest touch gestures, brightness interaction or Wi-Fi association.

## Existing-filesystem reset

Only supporting installer recovery advertises `reset-ext4-v1`; launcher mode refuses
`reset-target`. The host package version alone does not establish support. Existing
clients and recovery retain the unformatted-only `format-target` operation.

After selecting ext4, a capable host offers Keep (default), Reset, or Cancel before
inspecting the installation directory. Reset identifies the partition label, ID,
capacity, type and UUID, warns that **all contents of the partition** will be lost
(including other roots, personal files and image caches), and requires the exact
phrase `reset <target ID>`. No backup is made. Partition boundaries and GPT stay
unchanged; formatting is not secure erasure. Cancelling before dispatch performs
neither formatting nor installation. Existing preparation still performs device-owned
journal replay and cleanup of recognized incomplete staging before this menu;
cancellation is not a guarantee of zero writes during preparation.

The protocol-1 request is `{selection_token, target, boot_id, operation_id,
expected_filesystem_uuid, confirmation}`. Operation IDs are 32 lowercase hex digits.
Reset accepts only unambiguous ext4 with a nonzero UUID on an eligible GPT Linux-data
partition. The device revalidates the complete selection, GPT, geometry, device
identity, unmounted state, swap/holder exclusions and read-only fencing before writes.
It records uncertainty and consumes the shared formatting permission first. Reset
uses a single formatter force option, never doubled force, and disables discard.

The reset receipt contains `{operation: "reset-target", boot_id, operation_id,
target, previous_filesystem_uuid, filesystem_uuid, filesystem: "ext4", status:
"complete"}`. Completion requires a new UUID, unchanged partition identity and GPT,
restored fencing, and a read-only emptiness check (only an empty `lost+found` is
allowed), followed by unmounting. The host validates the receipt against its saved
request, then rediscovers the same partition with the new UUID, same capacity and
boot, and a new selection token. Only then may destination selection continue.

Host and device persist `status: "unknown"` before dispatch/writes respectively.
Interrupted, failed or unverified reset stops installation and is never automatically
retried. Inspect storage and the boot-scoped `last-format.json` report before making
any new destructive choice. Successful reset followed by failed rediscovery retains
the completed receipt but also stops installation. A new recovery boot does not
constitute permission to repeat a reset. Later destination failures or cancellation
retain the completed reset receipt and explicitly report that previous contents
were destroyed. Abort also reports completed resets, and preserves an unknown
reset outcome without entering storage cleanup.
2 changes: 1 addition & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The bootstrap installs `releases/latest/download/requirements.txt`. This file se

## GitHub Actions

`.github/workflows/build.yml` tests on Python 3.9 and 3.12 for pull requests and `main`. It downloads a published hardware wheel and verifies its release checksums; no sibling source checkout is used. The default hardware tag is explicit in the workflow and must be updated with the package dependency when selecting a new pair. Manual dispatch also lets you choose a hardware tag.
`.github/workflows/build.yml` tests on Python 3.9 and 3.12 for pull requests and `main`. PR tests build the exact companion hardware source pinned in `ci/hardware-test.json`; update that pin explicitly when the companion changes. Other builds download a published hardware wheel and verify its release checksums, or use an explicitly selected hardware workflow artifact. The default hardware tag must satisfy the package dependency. Manual dispatch accepts a hardware repository, tag, and optional workflow run ID for candidate pairing.

A version tag matching `pyproject.toml` creates a **draft** release containing the wheel, sdist, hashed `requirements.txt` and `SHA256SUMS`. Manual builds only retain workflow artifacts for seven days. The hardware release must be published first; cross-repository drafts are not public dependency URLs. Nothing publishes automatically or operates a device.

Expand Down
6 changes: 4 additions & 2 deletions src/orchard_cli/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def __init__(self, connection, progress):
self.ssh, self.progress = connection.argv, progress

def call(self, operation, request=None, *, source=None, size=0, timeout=120):
if operation not in ('status', 'prepare', 'targets', 'destination', 'install', 'handoff', 'diagnostics', 'abort', 'bootstrap', 'revoke-ssh', 'format-target', 'installations', 'launch'):
if operation not in ('status', 'prepare', 'targets', 'destination', 'install', 'handoff', 'diagnostics', 'abort', 'bootstrap', 'revoke-ssh', 'format-target', 'reset-target', 'installations', 'launch'):
raise ValueError('Unsupported phase operation')
try:
self.connection.check()
Expand Down Expand Up @@ -95,7 +95,9 @@ def collect_errors():
if (timed_out.is_set() or code or errors or len(records) != 1 or
(records and records[0].get('operation') != operation)):
detail = '; '.join(errors) or b''.join(stderr).decode(errors='replace')[-2000:]
if operation in ('prepare', 'install', 'handoff', 'abort', 'format-target') and 'orchard install' not in detail:
if operation == 'reset-target':
detail += '\nReset outcome is unknown; do not retry reset. Inspect storage and the saved formatting report.'
elif operation in ('prepare', 'install', 'handoff', 'abort', 'format-target') and 'orchard install' not in detail:
detail += '\nA fresh recovery boot is required; then run: orchard install'
raise DeviceError(f'{operation} failed. {detail}', reported=bool(errors))
return records[0]['result']
Loading