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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/mpkg-action-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Check public MPKG Action
on:
push:
branches: [staging, main, master]
paths:
- actions/build-mpkg/**
- crates/mpack/**
- .github/workflows/mpkg-action-check.yml
pull_request:
paths:
- actions/build-mpkg/**
- crates/mpack/**
- .github/workflows/mpkg-action-check.yml
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.12'
- name: Build bundled mpack without the OS or AppCore workspace
shell: bash
run: |
python3 - <<'PY'
import importlib.util, os
from pathlib import Path
action = Path('actions/build-mpkg').resolve()
spec = importlib.util.spec_from_file_location('action', action / 'package.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
scratch = Path(os.environ['RUNNER_TEMP']) / 'mpkg-tool-test'
scratch.mkdir()
tool = module.bundled_tool(action, scratch)
with open(os.environ['GITHUB_ENV'], 'a') as output:
output.write('MPKG_TEST_MPACK=' + str(tool) + '\n')
PY
- name: Test package boundaries and the real container format
run: python3 -m unittest discover -s actions/build-mpkg/tests -v
- name: Prepare an independent publisher's payload
run: |
mkdir -p test-input/payload/bundle
printf 'format = 1\n[package]\nid = "net.example.independent"\nkind = "resource"\n' > test-input/manifest.toml
printf 'Independent publisher\n' > test-input/payload/bundle/example.txt
- name: Exercise the composite Action
id: mpkg
uses: ./actions/build-mpkg
with:
manifest: test-input/manifest.toml
payload: test-input/payload
output: test-output/Example.mpkg
mpack: ${{ env.MPKG_TEST_MPACK }}
- name: Verify the returned checksum
env:
PACKAGE: ${{ steps.mpkg.outputs.package }}
EXPECTED: ${{ steps.mpkg.outputs.sha256 }}
run: |
python3 - <<'PY'
import hashlib, os
from pathlib import Path
assert hashlib.sha256(Path(os.environ['PACKAGE']).read_bytes()).hexdigest() == os.environ['EXPECTED']
PY
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions actions/build-mpkg/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Build an MPKG in GitHub Actions

This public composite Action creates an **unsigned MPKG v1** from an existing
manifest and prebuilt payload. It accepts packages from any publisher: native
applications, command-line tools, libraries, and resources supported by MPKG.
It requires no mochiOS organization membership, GitHub token, signing identity,
AppStore account, or particular repository layout.

Compilation remains a previous workflow step, using the language and SDK chosen
by the developer. The Action does not invoke the mochiOS image build, generate
publisher identities, alter capabilities, sign packages, or publish releases.

```yaml
permissions:
contents: read

jobs:
package:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
# Build your software and prepare manifest.toml and payload/ here.
- name: Create the unsigned package
id: mpkg
uses: mochiOS/devkit/actions/build-mpkg@staging
with:
manifest: build/package/manifest.toml
payload: build/package/payload
output: dist/Example.mpkg
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: unsigned-mpkg
path: |
${{ steps.mpkg.outputs.package }}
${{ steps.mpkg.outputs.checksum-file }}
if-no-files-found: error
```

`staging` above is the development example. For production, pin the Action to a
reviewed full commit SHA. No stable Action version is published by this change.

## Inputs and outputs

| Input | Meaning |
| --- | --- |
| `manifest` | Required, completed UTF-8 MPKG manifest, `format = 1`. |
| `payload` | Required, directory with the exact prebuilt payload. |
| `output` | New MPKG path, default `dist/package.mpkg`. |
| `mpack` | Optional trusted executable path; otherwise build the bundled mpack. |

The output directory must be fresh: an existing package or `SHA256SUMS` is never
overwritten. Use a separate output directory for each package. Symlink and
hard-linked payload files are rejected by mpack. Payload contents follow the ordinary MPKG layout: `bundle/` for application
files and `root/` for filesystem-root payloads. Manifest file bindings, resource
paths and capabilities are preserved exactly; this Action does not derive or
repair missing bindings. It performs packaging and container checks, not signer,
installation-policy or runtime validation.

Outputs are `package` (absolute MPKG path), `sha256` (whole-file SHA-256), and
`checksum-file` (absolute `SHA256SUMS` path). A checksum is not a code signature.
Artifact upload, signing, release approval and publication belong to the caller's
workflow. Official mochiOS apps use the same Action as third-party developers.

## Runner requirements

The initial supported runner is `ubuntu-24.04` (Python 3.12+, Bash). Either provide
`mpack`, or have Cargo/Rust and network access to fetch locked crates. The bundled
tool is built as a standalone host project from this Action's own source and
Cargo.lock; the OS/AppCore workspace is not required. Native mochiOS programs are
never executed on the host by this Action.

Local invocation, useful for debugging the same operation:

```sh
MPKG_MANIFEST=build/package/manifest.toml \
MPKG_PAYLOAD=build/package/payload \
MPKG_OUTPUT=dist/Example.mpkg \
MPKG_TOOL=/path/to/mpack \
python3 /path/to/devkit/actions/build-mpkg/package.py
```
42 changes: 42 additions & 0 deletions actions/build-mpkg/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Build mochiOS package
description: Create an unsigned MPKG from a manifest and prebuilt payload, for any publisher or package kind.
author: mochiOS
branding:
icon: package
color: orange
inputs:
manifest:
description: Path to the completed MPKG manifest.toml.
required: true
payload:
description: Directory containing the already-built MPKG payload.
required: true
output:
description: Destination MPKG path; must not already exist or be inside the payload.
required: false
default: dist/package.mpkg
mpack:
description: Optional path to an existing trusted mpack executable. Otherwise build the version bundled with this Action.
required: false
outputs:
package:
description: Absolute path of the generated unsigned MPKG.
value: ${{ steps.package.outputs.package }}
sha256:
description: SHA-256 of the complete unsigned MPKG file.
value: ${{ steps.package.outputs.sha256 }}
checksum-file:
description: Absolute path of the generated SHA256SUMS file.
value: ${{ steps.package.outputs.checksum-file }}
runs:
using: composite
steps:
- id: package
shell: bash
env:
MPKG_ACTION_PATH: ${{ github.action_path }}
MPKG_MANIFEST: ${{ inputs.manifest }}
MPKG_PAYLOAD: ${{ inputs.payload }}
MPKG_OUTPUT: ${{ inputs.output }}
MPKG_TOOL: ${{ inputs.mpack }}
run: python3 "$MPKG_ACTION_PATH/package.py"
121 changes: 121 additions & 0 deletions actions/build-mpkg/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Public MPKG Action: no publisher policy, credentials, signing, or publication."""
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import tomllib


def digest(path):
"""Hash a package without loading it into memory."""
result = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
result.update(chunk)
return result.hexdigest()


def checked_path(value, label):
"""Reject multiline Action outputs and ambiguous filesystem names."""
if not value or any(c in value for c in ('\r', '\n', '\0')):
raise ValueError('Invalid ' + label + ' path')
return Path(value).absolute()


def validate(manifest, payload, output):
"""Check input boundaries without inventing, rewriting, or trusting package metadata."""
if not manifest.is_file() or not payload.is_dir():
raise ValueError('A regular manifest file and payload directory are required')
data = tomllib.loads(manifest.read_text(encoding='utf-8'))
if type(data.get('format')) is not int or data['format'] != 1 or not isinstance(data.get('package'), dict):
raise ValueError('Expected a completed format-1 MPKG manifest')
if output.exists() or output.is_symlink():
raise ValueError('Refusing to overwrite an existing output')
if output.resolve().is_relative_to(payload.resolve()) or output.resolve() == manifest.resolve():
raise ValueError('Output must be outside the payload and must not alias the manifest')
checksum = output.parent / 'SHA256SUMS'
if checksum.exists() or checksum.is_symlink():
raise ValueError('Refusing to overwrite an existing SHA256SUMS file; use a fresh output directory')
return checksum


def bundled_tool(action, temporary):
"""Build only mpack from this Action revision, independently of SDK/OS workspaces."""
devkit = action.parents[1]
project = temporary / 'mpack'
project.mkdir()
shutil.copyfile(devkit / 'crates/mpack/Cargo.toml', project / 'Cargo.toml')
shutil.copyfile(action / 'tool.Cargo.lock', project / 'Cargo.lock')
shutil.copytree(devkit / 'crates/mpack/src', project / 'src')
# Cargo resolves a standalone tool, never the AppCore/compiler workspace.
with (project / 'Cargo.toml').open('a') as stream:
stream.write('\n[workspace]\n')
target = temporary / 'target'
env = os.environ.copy()
for key in ('RUSTFLAGS', 'CARGO_ENCODED_RUSTFLAGS', 'RUSTC', 'RUSTDOC', 'CARGO_BUILD_TARGET'):
env.pop(key, None)
subprocess.run(['cargo', 'build', '--locked', '--release', '--manifest-path', str(project / 'Cargo.toml'),
'--target-dir', str(target)], check=True, cwd=temporary, env=env)
return target / 'release' / ('mpack.exe' if os.name == 'nt' else 'mpack')


def create(manifest, payload, output, tool):
"""Run the ordinary packager and attest file integrity, never signature validity."""
checksum = validate(manifest, payload, output)
output.parent.mkdir(parents=True, exist_ok=True)
# Publish completed outputs only. Failed mpack cannot leave a final artifact.
with tempfile.TemporaryDirectory(prefix='.mpkg-', dir=output.parent) as folder:
staged = Path(folder) / 'package.mpkg'
subprocess.run([str(tool), 'create', '--manifest', str(manifest), '--payload', str(payload),
'--output', str(staged)], check=True)
with staged.open('rb') as stream:
header = stream.read(32)
if (len(header) != 32 or header[:12] != b'MPKG\x01\0\0\0\x20\0\0\0'
or header[20:] != bytes(12) or int.from_bytes(header[12:20], 'little') != staged.stat().st_size - 32):
raise ValueError('mpack did not generate an unsigned MPKG v1')
sha256 = digest(staged)
staged_checksum = Path(folder) / 'SHA256SUMS'
staged_checksum.write_text(sha256 + ' ' + output.name + '\n', encoding='utf-8')
# Same-filesystem links publish complete files, exclusively and atomically.
# The temporary names disappear on exit, leaving ordinary single-link files.
published = False
try:
os.link(staged, output)
published = True
os.link(staged_checksum, checksum)
except FileExistsError:
if published and output.exists() and os.path.samefile(staged, output):
output.unlink()
raise ValueError('Output appeared while packaging; refusing to replace it')
except OSError:
if published and output.exists() and os.path.samefile(staged, output):
output.unlink()
raise
return {'package': str(output), 'sha256': sha256, 'checksum-file': str(checksum)}


def main():
"""Read untrusted Action inputs as data, without shell interpolation."""
manifest = checked_path(os.environ.get('MPKG_MANIFEST', ''), 'manifest')
payload = checked_path(os.environ.get('MPKG_PAYLOAD', ''), 'payload')
output = checked_path(os.environ.get('MPKG_OUTPUT', 'dist/package.mpkg'), 'output')
validate(manifest, payload, output)
action = Path(__file__).resolve().parent
scratch = os.environ.get('RUNNER_TEMP')
with tempfile.TemporaryDirectory(prefix='mpkg-action-', dir=scratch) as folder:
configured = os.environ.get('MPKG_TOOL', '')
tool = checked_path(configured, 'mpack') if configured else bundled_tool(action, Path(folder))
results = create(manifest, payload, output, tool)
if os.environ.get('GITHUB_OUTPUT'):
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as stream:
for key, value in results.items():
stream.write(key + '=' + value + '\n')
print('Created unsigned MPKG: ' + results['package'])
print('SHA-256: ' + results['sha256'])


if __name__ == '__main__':
main()
Loading
Loading