Skip to content
Draft
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
88 changes: 85 additions & 3 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,109 @@ name: Build installer zip

on:
workflow_call:
inputs:
ref:
type: string
required: false
outputs:
commit:
value: ${{ jobs.build.outputs.commit }}
pull_request:
workflow_dispatch:
push:
branches:
- main

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
outputs:
commit: ${{ steps.checkout.outputs.commit }}
steps:
- uses: actions/checkout@v4

id: checkout
with:
ref: ${{ inputs.ref }}
persist-credentials: false

- name: Package installer
run: |
wget -O install_opencentauri/decrypt https://github.com/OpenCentauri/OpenCentauri/releases/download/v0.0.0/decrypt
wget -O install_opencentauri/recovery https://github.com/OpenCentauri/OpenCentauri/releases/download/v1.1.0/recovery
wget -O install_opencentauri/wifi-network-config-tool https://github.com/OpenCentauri/OpenCentauri/releases/latest/download/wifi-network-config-tool-linux-armv7
wget -O install_opencentauri/utf8-fix https://github.com/OpenCentauri/OpenCentauri/releases/latest/download/utf8-fix-linux-armv7
zip -r oc-installer.zip install_opencentauri

mkdir -p build/cc1
cp -r install_opencentauri build/cc1/

- uses: actions/upload-artifact@v4
with:
name: installer
name: installer-cc1
path: build/cc1
if-no-files-found: error

- uses: actions/upload-artifact@v4
with:
name: oc-installer.zip
path: oc-installer.zip
if-no-files-found: error

- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc-arm-linux-gnueabihf busybox shellcheck

- name: Cache pigz build
uses: actions/cache@v4
with:
path: build/pigz
key: pigz-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tools/build-pigz.sh') }}

- name: Package C2/CC2 installers
run: ./build.sh

- uses: actions/upload-artifact@v4
with:
name: installer-cc2
path: build/cc2_eeb001_installer_*.zip.sig
if-no-files-found: error

- uses: actions/upload-artifact@v4
with:
name: installer-c2
path: build/c2_eeb001_installer_*.zip.sig
if-no-files-found: error

comment:
needs: build
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
actions: read
pull-requests: write
steps:
- uses: actions/github-script@v7
continue-on-error: true
with:
script: |
const marker = '<!-- installer-artifacts -->';
const issue = {...context.repo, issue_number: context.issue.number};
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({
...context.repo, run_id: context.runId,
});
const links = artifacts.filter(a => a.name.startsWith('installer-'))
.map(a => `- [${a.name}](${runUrl}/artifacts/${a.id})`).join('\n');
const body = `${marker}\n## Build artifacts\n\n` +
`Build: \`${context.payload.pull_request.head.sha.slice(0, 7)}\`\n\n` +
`${links}\n\n[View workflow run](${runUrl})`;
const comments = await github.paginate(github.rest.issues.listComments, issue);
const previous = comments.find(c => c.user?.type === 'Bot' && c.body?.includes(marker));
if (previous) {
await github.rest.issues.updateComment({...context.repo, comment_id: previous.id, body});
} else {
await github.rest.issues.createComment({...issue, body});
}
45 changes: 30 additions & 15 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ on:
type: string
default: 'main'
version:
description: 'Version to release (e.g., 1.0.0). If not provided, will be inferred from the latest tag.'
required: false
description: 'Release tag (e.g., v1.0.0)'
required: true
type: string
draft:
description: 'Create as draft release'
Expand All @@ -22,34 +22,49 @@ on:
default: true

permissions:
contents: write # Required by softprops/action-gh-release@v2 to create releases and upload artifacts
id-token: write # Required by actions/attest@v2 for OIDC authentication with the attestation service
attestations: write # Required by actions/attest@v2 to create and store attestations
contents: read

jobs:
build:
name: Build
uses: ./.github/workflows/build.yaml
permissions:
contents: read
actions: read
pull-requests: write
with:
ref: ${{ inputs.branch || github.sha }}

release:
name: Create Release
needs: build
if: github.event_name != 'workflow_dispatch' || inputs.version != ''
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Download all artifacts
- name: Download CC1 release zip
uses: actions/download-artifact@v4
with:
pattern: oc-installer.zip
path: artifacts

- name: Download C2/CC2 zip.sig artifacts
uses: actions/download-artifact@v4
with:
pattern: installer-c*2
path: artifacts

- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.event.inputs.version || github.ref_name }}
draft: ${{ github.event.inputs.draft || false }}
tag_name: ${{ inputs.version || github.ref_name }}
target_commitish: ${{ needs.build.outputs.commit }}
draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft }}
prerelease: false
generate_release_notes: false
files: artifacts/**/*
fail_on_unmatched_files: true

- name: Attest Release
uses: actions/attest@v2
Expand All @@ -58,7 +73,7 @@ jobs:
predicate-type: 'https://in-toto.io/attestation/release/v0.1'
predicate: |
{
"version": "${{ github.event.inputs.version || github.ref_name }}",
"commit": "${{ github.sha }}",
"branch": "${{ github.event.inputs.branch || github.ref_name }}"
}
"version": ${{ toJSON(inputs.version || github.ref_name) }},
"commit": ${{ toJSON(needs.build.outputs.commit) }},
"branch": ${{ toJSON(inputs.branch || github.ref_name) }}
}
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,69 @@ This installer can
5. On your Centauri Carbon, navigate to the files tab, `Usb Drive`, then `install_opencentauri`
6. Touch hold the `IMPORT_ME_DO_NOT_PRINT` file, then select `Import`
7. Run `Install OpenCentauri (Online)`
8. Restart when the install process finished
8. Restart when the install process finished

## Centauri 2 / Centauri Carbon 2

This installer can
- Install COSMOS online or from USB
- Check the boot slot
- Install or remove a root shell
- Back up the eMMC to USB

### Usage
1. Download `installer-c2` or `installer-cc2` from the build artifacts
2. Extract the `.zip.sig` file onto a FAT32-formatted thumbdrive
3. Insert the drive and select the update in the printer's update menu
4. Choose a tool, then `Exit and Reboot` when finished

The update opens the installer without flashing firmware. It stops the stock
updater and clears its pending update flags. Only the COSMOS install buttons
flash firmware. C2 and CC2 use the same COSMOS image.

For an offline install, put `cosmos-centauri-carbon-2.swu` on the drive as
`install_c2/update.swu`.

### Root shell

Installing starts the shell immediately and at boot. Connect with
`nc <printer-ip> 4567`. There is no authentication; use a trusted network and
remove the shell when finished. `Reload menu` updates the install/remove label.
After a stock OTA wipe, reinstall with
`sh /opt/usr/sig-dump-emmc/reinstall.sh`.

### eMMC backup

Keep at least 5 GiB free. Do not print or remove the USB drive during backup.
The screen shows bytes read and percentage, then checks the gzip archive.
Compression uses two-worker pigz at level 1; the bundled static binary also
checks the archive.
This copies a running system, not a consistent filesystem snapshot.

Each run creates a dated `c2-emmc-backup-*` directory. Previous backups are
left alone. A `.partial` directory is incomplete; a finished backup contains
`OK.txt`. Remove unwanted backups on your computer to reclaim space.

FAT32 output is split into 1 GiB parts. exFAT/ext4 output is a single gzip file,
if the printer supports and mounts that filesystem. On a computer:

```sh
# FAT32: reassemble the parts inside the backup directory
cat c2-emmc-backup.bin.gz.part-* > c2-emmc-backup.bin.gz

gzip -t c2-emmc-backup.bin.gz
gzip -dc c2-emmc-backup.bin.gz | wc -c
```

The decompressed size on the tested C2/CC2 eMMC is `7837581312` bytes. Use host
tools for this check; the stock printer's `wc` counter wraps above 4 GiB.

### Building

Run `./build.sh` with Python 3.11+, OpenSSL, BusyBox, ShellCheck, curl, make
and `gcc-arm-linux-gnueabihf` installed.
It builds static pigz from pinned musl/zlib/pigz sources, tests the scripts,
and writes both `.zip.sig` files under `build/`. CI caches the pigz build.
Third-party notices are included in the installer.
The GUI and root-shell binaries come from OpenCentauri releases. The OTA
wrapper follows the format used by [COSMOS](https://github.com/OpenCentauri/cosmos).
16 changes: 16 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")"

sh tools/build-pigz.sh

python3 tests/test_build.py
python3 -m unittest discover -s tests -p "test_*.py"
python3 tests/test_install_scripts.py

python3 tools/make_swu.py
python3 tools/make_zipsig.py build/c2-tools-bootstrap.swu --out build/

python3 tests/test_build.py --post-build

ls -l build/
112 changes: 112 additions & 0 deletions install_c2/backup-emmc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/bin/sh

set -e
export LC_ALL=C

cd "$(dirname "$0")"
compressor="$PWD/pigz"
[ -x "$compressor" ] || { echo "Missing pigz in installer"; exit 1; }

usb=""
for path in /mnt/exUDISK /mnt/UDISK; do
if awk -v path="$path" '$2 == path && $1 ~ /^\/dev\/sd/ { found=1 } END { exit !found }' /proc/mounts; then
usb="$path"
break
fi
done
if [ -z "$usb" ]; then
echo "No USB drive found"
exit 1
fi

free=$(df -Pk "$usb" | awk 'END {print $4}')
if [ "${free:-0}" -lt 5242880 ]; then
echo "At least 5 GiB of free space is required. Existing backups have not been changed."
exit 1
fi

# Keep arithmetic in blocks so it fits in the stock 32-bit shell.
sectors=$(cat /sys/class/block/mmcblk0/size)
case "$sectors" in ''|*[!0-9]*) echo "Cannot read eMMC size"; exit 1 ;; esac
[ "$sectors" -gt 0 ] && [ "$((sectors % 64))" -eq 0 ] || exit 1
blocks=$((sectors / 64))
total=$(awk -v n="$blocks" 'BEGIN {printf "%.0f", n * 32768}')
chunk_blocks=1024
part_size=1073741824

# Each run gets its own directory. Never overwrite a previous backup.
backup="$usb/c2-emmc-backup-$(date +%Y%m%d-%H%M%S)"
work="$backup.partial"
if [ -e "$backup" ] || ! mkdir "$work"; then
echo "Backup directory already exists or is not writable: $work"
exit 1
fi
cleanup() {
rc=$?
trap - EXIT
if [ "$rc" -ne 0 ]; then
echo "Backup failed. Previous backups have not been changed."
echo "Backup incomplete" > "$work/FAILED.txt" 2>/dev/null || :
fi
exit "$rc"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
printf '%s bytes\n' "$total" > "$work/STARTED.txt"
echo "Backing up $total bytes to $backup"
echo "Do not print, remove the drive or turn off the printer during the backup."
sync

# fd 3 is the GUI output; stdout in this function carries only disk data.
exec 3>&1
read_emmc() {
offset=0
while [ "$offset" -lt "$blocks" ]; do
count=$((blocks - offset))
[ "$count" -le "$chunk_blocks" ] || count=$chunk_blocks
if ! dd if=/dev/mmcblk0 bs=32768 skip="$offset" count="$count" 2> "$work/dd.log"; then
cat "$work/dd.log" >&3
return 1
fi
# dd returns success at early EOF too. Require every requested block.
grep -qx "$count+0 records out" "$work/dd.log" || return 1
offset=$((offset + count))
awk -v n="$offset" -v total="$total" 'BEGIN {
printf "Read %.0f / %s bytes (%d%%)\n", n * 32768, total, n * 32768 * 100 / total
}' >&3
done
touch "$work/read.ok"
}

write_archive() {
if awk -v path="$usb" '$2 == path && $3 == "vfat" { found=1 } END { exit !found }' /proc/mounts; then
part=0
while :; do
file=$(printf '%s/c2-emmc-backup.bin.gz.part-%03d' "$work" "$part")
# head counts bytes, unlike dd count= on short pipe reads.
head -c "$part_size" > "$file" || return 1
if [ ! -s "$file" ]; then
rm "$file"
break
fi
part=$((part + 1))
done
else
cat > "$work/c2-emmc-backup.bin.gz"
fi
}

read_emmc | { "$compressor" -1 -p 2 && touch "$work/gzip.ok"; } | write_archive
# POSIX pipelines only return the last command's status.
[ -f "$work/read.ok" ] && [ -f "$work/gzip.ok" ] || exit 1
sync

echo "Read complete. Checking gzip archive..."
cat "$work"/c2-emmc-backup.bin.gz* | "$compressor" -t
rm "$work/read.ok" "$work/gzip.ok" "$work/dd.log"
printf '%s bytes read; gzip integrity checked\n' "$total" > "$work/OK.txt"
mv "$work" "$backup"
sync
trap - EXIT HUP INT TERM
echo "Backup complete: $backup"
echo "Safe to reboot."
Binary file added install_c2/bind-shell
Binary file not shown.
1 change: 1 addition & 0 deletions install_c2/bind-shell.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
b50d5347ff7db1d27e9b35f3bbf58ee5fddf2ae4156558b740cf61eaf6c76e6a install_c2/bind-shell
Loading
Loading