From c978f59725ab4995daa9e0df9b2df8b7d30e7182 Mon Sep 17 00:00:00 2001 From: tas0dev <156896971+tas0dev@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:13:29 +0900 Subject: [PATCH 1/6] Prepare standalone Rust application builds with a pinned released SDK --- .github/workflows/rust-sdk-check.yml | 17 + actions/setup-rust-sdk/action.yml | 22 + rust-sdk/README.md | 50 + rust-sdk/build.py | 154 ++ rust-sdk/komeup.Cargo.lock | 2113 ++++++++++++++++++++++++++ rust-sdk/setup.py | 146 ++ rust-sdk/sources.lock.json | 46 + rust-sdk/tests/test_build.py | 71 + 8 files changed, 2619 insertions(+) create mode 100644 .github/workflows/rust-sdk-check.yml create mode 100644 actions/setup-rust-sdk/action.yml create mode 100644 rust-sdk/README.md create mode 100644 rust-sdk/build.py create mode 100644 rust-sdk/komeup.Cargo.lock create mode 100644 rust-sdk/setup.py create mode 100644 rust-sdk/sources.lock.json create mode 100644 rust-sdk/tests/test_build.py diff --git a/.github/workflows/rust-sdk-check.yml b/.github/workflows/rust-sdk-check.yml new file mode 100644 index 0000000..1e9d1bd --- /dev/null +++ b/.github/workflows/rust-sdk-check.yml @@ -0,0 +1,17 @@ +name: Check standalone Rust application SDK +on: + push: + branches: [staging, main, master] + paths: ['rust-sdk/**', 'actions/setup-rust-sdk/**', '.github/workflows/rust-sdk-check.yml'] + pull_request: + paths: ['rust-sdk/**', 'actions/setup-rust-sdk/**', '.github/workflows/rust-sdk-check.yml'] +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - run: python3 -m unittest discover -s rust-sdk/tests -v diff --git a/actions/setup-rust-sdk/action.yml b/actions/setup-rust-sdk/action.yml new file mode 100644 index 0000000..f9d5d96 --- /dev/null +++ b/actions/setup-rust-sdk/action.yml @@ -0,0 +1,22 @@ +name: Set up mochiOS Rust application SDK +description: Install a pinned C SDK through komeup and prepare Rust, AppCore and ViewKit without an OS checkout. +inputs: + output: + description: Fresh directory for the isolated SDK (prefer runner.temp). + required: true +outputs: + sdk: + description: Local app-build.json, for this runner only; do not publish it. + value: ${{ steps.setup.outputs.sdk }} + helper: + description: Path to the generic Rust app build/test/staging helper. + value: ${{ steps.setup.outputs.helper }} +runs: + using: composite + steps: + - id: setup + shell: bash + env: + SDK_OUTPUT: ${{ inputs.output }} + SDK_ACTION: ${{ github.action_path }} + run: python3 "$SDK_ACTION/../../rust-sdk/setup.py" --output "$SDK_OUTPUT" diff --git a/rust-sdk/README.md b/rust-sdk/README.md new file mode 100644 index 0000000..b433942 --- /dev/null +++ b/rust-sdk/README.md @@ -0,0 +1,50 @@ +# Standalone Rust application builds + +`actions/setup-rust-sdk` prepares a native Rust application build environment on +Ubuntu 24.04. It installs the explicit released C/C++ SDK through komeup, verifies +the reviewed archive SHA-256, and fetches the fixed Rust std, ViewKit, font and +protocol sources from `sources.lock.json`. AppCore comes from the same DevKit +revision as the Action. No OS checkout, image build, SDK release, credential or +organization membership is required. + +This initial source SDK prepares one application at a time. It uses the existing +mochiOS Rust std fork and pinned nightly compiler. It is not a newly published +binary Rust SDK. The runner-local `app-build.json` contains local paths and must +not be uploaded or shipped. Immutable source pins and font hashes are public; +the installation does not change the developer's default komeup/rustup choices. + +Example caller (replace `staging` with a reviewed full commit SHA): + +```yaml +- id: sdk + uses: mochiOS/devkit/actions/setup-rust-sdk@staging + with: + output: ${{ runner.temp }}/mochios-rust-sdk +- name: Test, compile and stage + env: + HELPER: ${{ steps.sdk.outputs.helper }} + SDK: ${{ steps.sdk.outputs.sdk }} + run: python3 "$HELPER" --source "$GITHUB_WORKSPACE" --sdk "$SDK" --output "$RUNNER_TEMP/app-build" --test +- id: package + uses: mochiOS/devkit/actions/build-mpkg@20d3fb71b952ae7a4ff583dafe8cc3d00e24d3a3 + with: + manifest: ${{ runner.temp }}/app-build/stage/manifest.toml + payload: ${{ runner.temp }}/app-build/stage/payload + output: dist/Example.mpkg +``` + +Compilation and native host unit tests run in an isolated staged Cargo project. +The helper maps the existing AppCore/protocol source dependencies into the SDK, +preserves the application's lockfile as input, and exports its actual resulting +resolution as `resolved.Cargo.lock` for review. It never edits or rolls back the +source checkout's Cargo.lock. SDK changes can require adopting this resolution +into the application repository before production release. + +The staging helper supports a single-entry Rust application with a completed +MPKG manifest. It preserves document associations and requested capabilities, +copies declared resources, binds real file hashes/modes, removes source-tree +built-in provenance, and changes the binary destination to the normal +`/applications/.app` package path. ELF and host-path checks do not execute +target programs and do not establish native runtime success. The public MPKG +Action separately creates the unsigned container. Signing, approval and release +publication remain caller workflow responsibilities. diff --git a/rust-sdk/build.py b/rust-sdk/build.py new file mode 100644 index 0000000..d9d2405 --- /dev/null +++ b/rust-sdk/build.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Build/test a Rust app and stage an unsigned package for the public MPKG Action.""" +import argparse +import copy +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import struct +import subprocess +import tomllib + + +def run(args, **kwargs): + """Preserve real compiler and test failures with streaming build output.""" + subprocess.run([str(a) for a in args], check=True, **kwargs) + + +def dumps(data): + """Write ordinary Cargo/MPKG tables, preserving nested metadata and array tables.""" + lines = [] + def table(mapping, path=(), header=None): + if header: + lines.append(header) + children = [] + for key, value in mapping.items(): + if isinstance(value, dict) or (isinstance(value, list) and value and isinstance(value[0], dict)): + children.append((key, value)) + else: + lines.append(json.dumps(key) + ' = ' + json.dumps(value, ensure_ascii=True, allow_nan=False)) + for key, value in children: + child = (*path, key) + name = '.'.join(json.dumps(p) for p in child) + for item in value if isinstance(value, list) else [value]: + lines.append('') + table(item, child, '[[' + name + ']]' if isinstance(value, list) else '[' + name + ']') + table(data) + return '\n'.join(lines) + '\n' + + +def resource(source, name): + """Find a declared bundle-local resource without following links outside source.""" + path = PurePosixPath(name) + if not name or path.is_absolute() or '..' in path.parts or '\\' in name: + raise ValueError('Invalid resource path: ' + name) + for candidate in (source / name, source / 'resources' / name): + if candidate.is_file() and candidate.resolve().is_relative_to(source.resolve()): + return candidate + raise ValueError('Missing or external resource: ' + name) + + +def stage(source, binary, output, licenses=None): + """Preserve capabilities/associations and bind actual files without claiming built-in trust.""" + with binary.open('rb') as stream: + header = stream.read(64) + if len(header) != 64 or header[:6] != b'\x7fELF\x02\x01' or struct.unpack_from('') + header = bytearray(64) + header[:6] = b'\x7fELF\x02\x01' + struct.pack_into(' Date: Wed, 11 Nov 2026 15:19:38 +0900 Subject: [PATCH 2/6] Use the pinned SDK compiler for host application tests --- rust-sdk/build.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust-sdk/build.py b/rust-sdk/build.py index d9d2405..11ee7cb 100644 --- a/rust-sdk/build.py +++ b/rust-sdk/build.py @@ -119,8 +119,9 @@ def build(source, output, sdk, tests): host_env = dict(os.environ, **sdk['fonts'], CARGO_PROFILE_TEST_DEBUG='0') for key in ('RUSTFLAGS', 'CARGO_ENCODED_RUSTFLAGS', 'RUSTC', 'RUSTDOC'): host_env.pop(key, None) + host_env.update(RUSTC=sdk['rustc'], RUSTDOC=sdk['rustdoc']) if tests: - run(['cargo', 'test', '--manifest-path', project / 'Cargo.toml', '--target-dir', output / 'host-target', *options], + run([sdk['cargo'], 'test', '--manifest-path', project / 'Cargo.toml', '--target-dir', output / 'host-target', *options], env=host_env, cwd=project) env = dict(host_env, RUSTC=sdk['rustc'], RUSTDOC=sdk['rustdoc'], CARGO_ENCODED_RUSTFLAGS='\x1f'.join(sdk['rustflags'] + ['--remap-path-prefix=' + str(source) + '=/src/app', From 9fbeaeaaeb8dcb597b662fe0cdc8df145d065395 Mon Sep 17 00:00:00 2001 From: tas0dev <156896971+tas0dev@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:30:05 +0900 Subject: [PATCH 3/6] Resolve Rust standard-library shims through the single overlay sysroot --- rust-sdk/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-sdk/setup.py b/rust-sdk/setup.py index b01512b..281a265 100644 --- a/rust-sdk/setup.py +++ b/rust-sdk/setup.py @@ -46,7 +46,7 @@ def configs(root): patches['https://github.com/mochiOS/syscalls'] = { tomllib.loads(p.read_text())['package']['name']: p.parent for p in (root / 'user/crates').glob('*/Cargo.toml')} for name in ('core', 'alloc', 'std'): - patches['crates-io']['rustc-std-workspace-' + name] = root / 'libraries/rust/library' / ('rustc-std-workspace-' + name) + patches['crates-io']['rustc-std-workspace-' + name] = root / 'rust-overlay/lib/rustlib/src/rust/library' / ('rustc-std-workspace-' + name) return ['patch.' + json.dumps(source) + '.' + json.dumps(name) + '.path=' + json.dumps(str(path)) for source, crates in patches.items() for name, path in crates.items()] From 7f21c1fc538d064cdd2bc13c81fb69c1703bbedf Mon Sep 17 00:00:00 2001 From: tas0dev <156896971+tas0dev@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:34:48 +0900 Subject: [PATCH 4/6] Verify approved package artifacts before signing and publication --- .github/workflows/rust-sdk-check.yml | 18 ++++ rust-sdk/README.md | 30 ++++++ rust-sdk/host-tool.py | 37 ++++++++ rust-sdk/release.py | 133 +++++++++++++++++++++++++++ rust-sdk/tests/test_release.py | 89 ++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 rust-sdk/host-tool.py create mode 100644 rust-sdk/release.py create mode 100644 rust-sdk/tests/test_release.py diff --git a/.github/workflows/rust-sdk-check.yml b/.github/workflows/rust-sdk-check.yml index 1e9d1bd..4fb4ab6 100644 --- a/.github/workflows/rust-sdk-check.yml +++ b/.github/workflows/rust-sdk-check.yml @@ -15,3 +15,21 @@ jobs: with: persist-credentials: false - run: python3 -m unittest discover -s rust-sdk/tests -v + signer: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CARGO_BUILD_JOBS: '4' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - id: sdk + uses: ./actions/setup-rust-sdk + with: + output: ${{ runner.temp }}/mochios-rust-sdk + - name: Test and build the trusted signer without application sources or credentials + env: + SDK: ${{ steps.sdk.outputs.sdk }} + HELPER: ${{ steps.sdk.outputs.helper }} + run: python3 "$(dirname "$HELPER")/host-tool.py" --sdk "$SDK" --tool msign --output "$RUNNER_TEMP/host-tools" --test diff --git a/rust-sdk/README.md b/rust-sdk/README.md index b433942..befbf08 100644 --- a/rust-sdk/README.md +++ b/rust-sdk/README.md @@ -48,3 +48,33 @@ built-in provenance, and changes the binary destination to the normal target programs and do not establish native runtime success. The public MPKG Action separately creates the unsigned container. Signing, approval and release publication remain caller workflow responsibilities. + +## Approval-protected publication + +The generic `release.py` helper consumes the unsigned artifact from the same +successful workflow run. It checks the source commit, package ID, actual MPKG +manifest, SDK source lock and archive checksum before accessing signing inputs. +It does not compile or execute application code. `host-tool.py` builds msign from +the same trusted SDK source revision, with developer-CA networking disabled. + +The caller must place its publication job behind a protected GitHub Environment. +Configure the repository's chosen reviewer/team and restrict the environment to +its default branch. The public packaging/SDK Actions have no organization or +team requirement of their own. + +Official app callers use an `app-release` Environment with +`application-maintainers` approval. Releases stay disabled until the repository +variable `APP_RELEASE_ENABLED` is explicitly set to `true` and these Environment +values have been configured: + +* Secret `MSIGN_CERTIFICATE_B64`: base64 of the issued Developer Certificate file. +* Secret `MSIGN_PRIVATE_KEY_B64`: base64 of its corresponding private-key file. +* Variable `MSIGN_ROOT_PUBLIC_KEY_B64`: base64 of the trusted issuer public-key file. + +No root private key, fixture key, generated substitute identity or unsigned +publication is accepted. Credentials are decoded into a private temporary +directory, not propagated as subprocess environment values, and removed on +success or failure. The signed package is verified against the trusted root +before publication. Existing release tags/assets are never replaced. The initial +release helper publishes prereleases as `v-r`; update the +manifest revision before making another release of the same version. diff --git a/rust-sdk/host-tool.py b/rust-sdk/host-tool.py new file mode 100644 index 0000000..0d4027b --- /dev/null +++ b/rust-sdk/host-tool.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Build a trusted SDK host tool without compiling or executing application code.""" +import argparse +import json +import os +from pathlib import Path +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sdk', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--tool', choices=['msign'], required=True) + parser.add_argument('--test', action='store_true') + args = parser.parse_args() + sdk = json.loads(args.sdk.read_text()) + devkit = Path(sdk['appcore']).parents[1] + env = os.environ.copy() + for key in ('RUSTFLAGS', 'CARGO_ENCODED_RUSTFLAGS', 'CARGO_BUILD_TARGET'): + env.pop(key, None) + env.update(RUSTC=sdk['rustc'], RUSTDOC=sdk['rustdoc']) + command = [sdk['cargo'], 'build', '--release', '--manifest-path', str(devkit / 'Cargo.toml'), + '--package', args.tool, '--no-default-features', '--target-dir', str(args.output.resolve())] + for config in sdk['cargo_config']: + command += ['--config', config] + if args.test: + test = command.copy() + test[1] = 'test' + test.remove('--release') + env['CARGO_PROFILE_TEST_DEBUG'] = '0' + subprocess.run(test, env=env, cwd=devkit, check=True) + subprocess.run(command, env=env, cwd=devkit, check=True) + + +if __name__ == '__main__': + main() diff --git a/rust-sdk/release.py b/rust-sdk/release.py new file mode 100644 index 0000000..d81a99a --- /dev/null +++ b/rust-sdk/release.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Sign and publish one approved build artifact; never compile application source.""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tarfile +import tempfile +import time +import tomllib + +HERE = Path(__file__).resolve().parent + + +def digest(path): + """Hash the complete artifact without trusting build metadata.""" + 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 validate(directory, expected_id, expected_commit): + """Bind the approved source revision, SDK, package identity, and actual bytes.""" + if not re.fullmatch('[0-9a-f]{40}', expected_commit): + raise ValueError('Release requires an exact source commit') + metadata = directory / 'build-info.json' + checksums = directory / 'SHA256SUMS' + packages = list(directory.glob('*.mpkg')) + if len(packages) != 1: + raise ValueError('Expected exactly one unsigned MPKG') + package = packages[0] + if any(p.is_symlink() or not p.is_file() for p in (metadata, checksums, package)): + raise ValueError('Release artifacts must be ordinary files') + if metadata.stat().st_size > 1024 * 1024 or checksums.stat().st_size > 4096: + raise ValueError('Artifact metadata exceeds the size limit') + record = json.loads(metadata.read_text()) + lock = json.loads((HERE / 'sources.lock.json').read_text()) + if (record.get('format') != 1 or record.get('source_commit') != expected_commit + or record.get('signed') is not False or record.get('sources') != lock): + raise ValueError('Artifact source/SDK does not match the approved release') + if checksums.read_text() != digest(package) + ' ' + package.name + '\n': + raise ValueError('Unsigned artifact checksum mismatch') + with package.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': + raise ValueError('Expected unsigned MPKG v1') + with tarfile.open(fileobj=stream, mode='r|') as archive: + first = next(iter(archive)) + if first.name != 'manifest.toml' or not first.isfile() or first.size > 1024 * 1024: + raise ValueError('Invalid MPKG manifest') + manifest = tomllib.loads(archive.extractfile(first).read().decode('utf-8')) + identity = manifest.get('package', {}) + if identity != record.get('package') or identity.get('id') != expected_id or 'provenance' in identity: + raise ValueError('MPKG identity differs from the approved application') + version, revision = identity.get('version'), identity.get('revision') + if (not isinstance(version, str) or not re.fullmatch('[0-9][0-9A-Za-z.+-]*', version) + or type(revision) is not int or revision < 1): + raise ValueError('Invalid release version/revision') + return package, identity, 'v' + version + '-r' + str(revision) + + +def sign(package, output, msign): + """Use explicit environment credentials, verify the result, and remove temporary keys.""" + required = ('MSIGN_CERTIFICATE_B64', 'MSIGN_PRIVATE_KEY_B64', 'MSIGN_ROOT_PUBLIC_KEY_B64') + if any(not os.environ.get(key) for key in required): + raise ValueError('Signing certificate, private key, and trusted root public key must be configured before enabling releases') + with tempfile.TemporaryDirectory(prefix='mpkg-signing-') as temporary: + root = Path(temporary) + root.chmod(0o700) + paths = [] + for key in required: + data = base64.b64decode(os.environ[key], validate=True) + if not data or len(data) > 1024 * 1024: + raise ValueError('Invalid signing credential file') + path = root / key + with path.open('xb') as stream: + path.chmod(0o600) + stream.write(data) + paths.append(path) + certificate, private, issuer = paths + env = dict(os.environ, MOCHIOS_SIGNING_HOME=str(root / 'identity')) + # Do not propagate the credential values to subprocess environments. + for key in required: + env.pop(key, None) + for key in ('GH_TOKEN', 'GITHUB_TOKEN'): + env.pop(key, None) + subprocess.run([str(msign), 'identity', 'import', 'ci-release', '--certificate', str(certificate), '--key', str(private)], env=env, check=True) + subprocess.run([str(msign), 'package', 'sign-auto', str(package), '--identity', 'ci-release', '--output', str(output)], env=env, check=True) + subprocess.run([str(msign), 'package', 'verify', str(output), '--root-public-key', str(issuer), '--unix-time', str(int(time.time()))], env=env, check=True) + + +def absent(repository, path): + """Require a definite 404; authentication/network failures are never interpreted as absence.""" + result = subprocess.run(['gh', 'api', '-i', 'repos/' + repository + '/' + path], capture_output=True, text=True) + if result.returncode == 0 or 'HTTP 404' not in result.stderr: + raise ValueError('Release/tag already exists or could not be checked; refusing to overwrite') + + +def main(): + """Only an approval-protected caller should execute this publication command.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--artifacts', type=Path, required=True) + parser.add_argument('--msign', type=Path, required=True) + parser.add_argument('--package-id', required=True) + parser.add_argument('--commit', required=True) + parser.add_argument('--repository', required=True) + args = parser.parse_args() + if not re.fullmatch('[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repository): + raise ValueError('Invalid release repository') + package, identity, tag = validate(args.artifacts.resolve(), args.package_id, args.commit) + absent(args.repository, 'releases/tags/' + tag) + absent(args.repository, 'git/ref/tags/' + tag) + with tempfile.TemporaryDirectory(prefix='mpkg-release-') as temporary: + root = Path(temporary) + output = root / package.name + sign(package, output, args.msign.resolve()) + checksums = root / 'SHA256SUMS' + checksums.write_text(digest(output) + ' ' + output.name + '\n') + notes = root / 'notes.md' + notes.write_text('Native mochiOS application package.\n\nSource commit: `' + args.commit + '`\n') + subprocess.run(['gh', 'release', 'create', tag, str(output), str(checksums), '--repo', args.repository, + '--target', args.commit, '--title', identity['name'] + ' ' + identity['version'] + ' (r' + str(identity['revision']) + ')', + '--prerelease', '--notes-file', str(notes)], check=True) + + +if __name__ == '__main__': + main() diff --git a/rust-sdk/tests/test_release.py b/rust-sdk/tests/test_release.py new file mode 100644 index 0000000..e3f999d --- /dev/null +++ b/rust-sdk/tests/test_release.py @@ -0,0 +1,89 @@ +import base64 +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +HERE = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('release_app', HERE / 'release.py') +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class ApprovedArtifact(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.commit = 'a' * 40 + self.identity = dict(id='net.example.editor', name='Editor', version='1.0.0', revision=1) + data = b'[package]\nid="net.example.editor"\nname="Editor"\nversion="1.0.0"\nrevision=1\n' + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w') as tar: + entry = tarfile.TarInfo('manifest.toml') + entry.size = len(data) + tar.addfile(entry, io.BytesIO(data)) + header = bytearray(32) + header[:12] = b'MPKG\x01\0\0\0\x20\0\0\0' + struct.pack_into(' Date: Wed, 11 Nov 2026 15:36:39 +0900 Subject: [PATCH 5/6] Bind application build receipts to the exact SDK Action revision --- actions/setup-rust-sdk/action.yml | 1 + rust-sdk/build.py | 2 +- rust-sdk/release.py | 9 +++++---- rust-sdk/setup.py | 4 +++- rust-sdk/tests/test_release.py | 12 ++++++------ 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/actions/setup-rust-sdk/action.yml b/actions/setup-rust-sdk/action.yml index f9d5d96..27e7124 100644 --- a/actions/setup-rust-sdk/action.yml +++ b/actions/setup-rust-sdk/action.yml @@ -19,4 +19,5 @@ runs: env: SDK_OUTPUT: ${{ inputs.output }} SDK_ACTION: ${{ github.action_path }} + SDK_ACTION_REF: ${{ github.action_ref }} run: python3 "$SDK_ACTION/../../rust-sdk/setup.py" --output "$SDK_OUTPUT" diff --git a/rust-sdk/build.py b/rust-sdk/build.py index 11ee7cb..7790fb2 100644 --- a/rust-sdk/build.py +++ b/rust-sdk/build.py @@ -142,7 +142,7 @@ def build(source, output, sdk, tests): package = stage(source, binary, output / 'stage', Path(sdk['font_licenses'])) revision = subprocess.check_output(['git', '-C', str(source), 'rev-parse', 'HEAD'], text=True).strip() (output / 'build-info.json').write_text(json.dumps(dict(format=1, source_commit=revision, - package=package['package'], sources=sdk['sources'], runtime_tested=False, signed=False), indent=2) + '\n') + package=package['package'], sources=sdk['sources'], devkit_ref=sdk['devkit_ref'], runtime_tested=False, signed=False), indent=2) + '\n') if __name__ == '__main__': diff --git a/rust-sdk/release.py b/rust-sdk/release.py index d81a99a..3d47d74 100644 --- a/rust-sdk/release.py +++ b/rust-sdk/release.py @@ -25,9 +25,9 @@ def digest(path): return result.hexdigest() -def validate(directory, expected_id, expected_commit): +def validate(directory, expected_id, expected_commit, expected_sdk_ref): """Bind the approved source revision, SDK, package identity, and actual bytes.""" - if not re.fullmatch('[0-9a-f]{40}', expected_commit): + if not re.fullmatch('[0-9a-f]{40}', expected_commit) or not re.fullmatch('[0-9a-f]{40}', expected_sdk_ref): raise ValueError('Release requires an exact source commit') metadata = directory / 'build-info.json' checksums = directory / 'SHA256SUMS' @@ -42,7 +42,7 @@ def validate(directory, expected_id, expected_commit): record = json.loads(metadata.read_text()) lock = json.loads((HERE / 'sources.lock.json').read_text()) if (record.get('format') != 1 or record.get('source_commit') != expected_commit - or record.get('signed') is not False or record.get('sources') != lock): + or record.get('signed') is not False or record.get('sources') != lock or record.get('devkit_ref') != expected_sdk_ref): raise ValueError('Artifact source/SDK does not match the approved release') if checksums.read_text() != digest(package) + ' ' + package.name + '\n': raise ValueError('Unsigned artifact checksum mismatch') @@ -109,11 +109,12 @@ def main(): parser.add_argument('--msign', type=Path, required=True) parser.add_argument('--package-id', required=True) parser.add_argument('--commit', required=True) + parser.add_argument('--sdk-ref', required=True) parser.add_argument('--repository', required=True) args = parser.parse_args() if not re.fullmatch('[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repository): raise ValueError('Invalid release repository') - package, identity, tag = validate(args.artifacts.resolve(), args.package_id, args.commit) + package, identity, tag = validate(args.artifacts.resolve(), args.package_id, args.commit, args.sdk_ref) absent(args.repository, 'releases/tags/' + tag) absent(args.repository, 'git/ref/tags/' + tag) with tempfile.TemporaryDirectory(prefix='mpkg-release-') as temporary: diff --git a/rust-sdk/setup.py b/rust-sdk/setup.py index 281a265..503d815 100644 --- a/rust-sdk/setup.py +++ b/rust-sdk/setup.py @@ -96,6 +96,8 @@ def prepare(output): raise ValueError('Use a fresh SDK output directory') root.mkdir(parents=True) lock = json.loads((HERE / 'sources.lock.json').read_text()) + revision = os.environ.get('SDK_ACTION_REF') or subprocess.check_output( + ['git', '-C', str(HERE.parent), 'rev-parse', 'HEAD'], text=True).strip() for name, spec in lock['sources'].items(): checkout(root, name, spec) devkit = root / 'tools/devkit' @@ -125,7 +127,7 @@ def prepare(output): raise ValueError('Font hash mismatch: ' + name) flags = ['--remap-path-prefix=' + str(Path.home()) + '=/build', '--remap-path-prefix=' + str(base) + '=/toolchain', '--remap-path-prefix=' + str(root) + '=/build/sdk', '-C', 'debuginfo=0', '-C', 'linker=' + str(sdk / 'bin/mochios-cc')] - metadata = dict(format=1, cargo=str(compiler / 'bin/cargo'), rustc=str(compiler / 'bin/rustc'), + metadata = dict(format=1, devkit_ref=revision, cargo=str(compiler / 'bin/cargo'), rustc=str(compiler / 'bin/rustc'), rustdoc=str(compiler / 'bin/rustdoc'), target=str(root / 'user/targets/x86_64-unknown-mochios.json'), target_dir=str(root / 'target'), rustflags=flags, cargo_config=configs(root), appcore=str(devkit / 'crates/appcore'), user=str(root / 'user'), diff --git a/rust-sdk/tests/test_release.py b/rust-sdk/tests/test_release.py index e3f999d..dd45634 100644 --- a/rust-sdk/tests/test_release.py +++ b/rust-sdk/tests/test_release.py @@ -36,7 +36,7 @@ def setUp(self): self.package = self.root / 'Editor.mpkg' self.package.write_bytes(header + stream.getvalue()) (self.root / 'SHA256SUMS').write_text(release.digest(self.package) + ' Editor.mpkg\n') - self.record = dict(format=1, source_commit=self.commit, signed=False, package=self.identity, + self.record = dict(format=1, source_commit=self.commit, devkit_ref=self.commit, signed=False, package=self.identity, sources=json.loads((HERE / 'sources.lock.json').read_text())) self.save() @@ -44,28 +44,28 @@ def save(self): (self.root / 'build-info.json').write_text(json.dumps(self.record)) def test_accepts_only_the_bound_artifact_and_source_commit(self): - self.assertEqual(release.validate(self.root, self.identity['id'], self.commit)[2], 'v1.0.0-r1') + self.assertEqual(release.validate(self.root, self.identity['id'], self.commit, self.commit)[2], 'v1.0.0-r1') for identity, commit in [('net.attacker.app', self.commit), (self.identity['id'], 'b' * 40)]: with self.assertRaises(ValueError): - release.validate(self.root, identity, commit) + release.validate(self.root, identity, commit, self.commit) def test_tampered_bytes_and_sdk_are_rejected(self): self.record['sources']['sdk']['sha256'] = 'b' * 64 self.save() with self.assertRaises(ValueError): - release.validate(self.root, self.identity['id'], self.commit) + release.validate(self.root, self.identity['id'], self.commit, self.commit) self.record['sources'] = json.loads((HERE / 'sources.lock.json').read_text()) self.save() with self.package.open('ab') as stream: stream.write(b'tampered') with self.assertRaises(ValueError): - release.validate(self.root, self.identity['id'], self.commit) + release.validate(self.root, self.identity['id'], self.commit, self.commit) def test_metadata_cannot_relabel_the_actual_manifest(self): self.record['package']['id'] = 'net.attacker.app' self.save() with self.assertRaises(ValueError): - release.validate(self.root, 'net.attacker.app', self.commit) + release.validate(self.root, 'net.attacker.app', self.commit, self.commit) def test_missing_keys_fail_before_invoking_a_signer(self): with patch.dict(os.environ, {}, clear=True), patch.object(release.subprocess, 'run') as run: From 1ae906d7417e9882b11541e338552c3039c981fb Mon Sep 17 00:00:00 2001 From: tas0dev <156896971+tas0dev@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:06:00 +0900 Subject: [PATCH 6/6] Require the configured approval Team and branch before app publication --- rust-sdk/README.md | 6 +++++- rust-sdk/release.py | 26 +++++++++++++++++++++++ rust-sdk/tests/test_release.py | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/rust-sdk/README.md b/rust-sdk/README.md index befbf08..18d979e 100644 --- a/rust-sdk/README.md +++ b/rust-sdk/README.md @@ -59,7 +59,11 @@ the same trusted SDK source revision, with developer-CA networking disabled. The caller must place its publication job behind a protected GitHub Environment. Configure the repository's chosen reviewer/team and restrict the environment to -its default branch. The public packaging/SDK Actions have no organization or +its default branch. Pass `--environment`, `--reviewer-team` (numeric Team ID), +and `--branch` to the release helper. It requires exactly that Team as reviewer, +admin bypass disabled, and exactly that branch allowed; unreadable or changed +policy aborts publication. This verifies configuration, while GitHub's protected +job enforces the approval itself. The public packaging/SDK Actions have no organization or team requirement of their own. Official app callers use an `app-release` Environment with diff --git a/rust-sdk/release.py b/rust-sdk/release.py index 3d47d74..5e39e2b 100644 --- a/rust-sdk/release.py +++ b/rust-sdk/release.py @@ -12,6 +12,7 @@ import tempfile import time import tomllib +from urllib.parse import quote HERE = Path(__file__).resolve().parent @@ -102,6 +103,27 @@ def absent(repository, path): raise ValueError('Release/tag already exists or could not be checked; refusing to overwrite') +def approval_policy(repository, environment, team, branch): + """Fail closed if the caller's required Team or branch restriction was removed.""" + endpoint = 'repos/' + repository + '/environments/' + quote(environment, safe='') + def fetch(path): + return json.loads(subprocess.check_output(['gh', 'api', path], text=True)) + policy = fetch(endpoint) + reviewers = [reviewer for rule in policy.get('protection_rules', []) + if rule.get('type') == 'required_reviewers' for reviewer in rule.get('reviewers', [])] + # GitHub accepts approval from any listed reviewer. Do not allow a different + # user/team to substitute for the Team explicitly selected by the publisher. + if (policy.get('can_admins_bypass') is not False or len(reviewers) != 1 + or reviewers[0].get('type') != 'Team' or reviewers[0].get('reviewer', {}).get('id') != team + or policy.get('deployment_branch_policy') != dict(protected_branches=False, custom_branch_policies=True)): + raise ValueError('Required Team approval or environment restrictions are missing') + branches = fetch(endpoint + '/deployment-branch-policies?per_page=100') + entries = branches.get('branch_policies', []) + if (branches.get('total_count') != 1 or len(entries) != 1 + or entries[0].get('name') != branch or entries[0].get('type') != 'branch'): + raise ValueError('Publication environment must allow only the selected branch') + + def main(): """Only an approval-protected caller should execute this publication command.""" parser = argparse.ArgumentParser(description=__doc__) @@ -111,10 +133,14 @@ def main(): parser.add_argument('--commit', required=True) parser.add_argument('--sdk-ref', required=True) parser.add_argument('--repository', required=True) + parser.add_argument('--environment', required=True) + parser.add_argument('--reviewer-team', type=int, required=True) + parser.add_argument('--branch', required=True) args = parser.parse_args() if not re.fullmatch('[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', args.repository): raise ValueError('Invalid release repository') package, identity, tag = validate(args.artifacts.resolve(), args.package_id, args.commit, args.sdk_ref) + approval_policy(args.repository, args.environment, args.reviewer_team, args.branch) absent(args.repository, 'releases/tags/' + tag) absent(args.repository, 'git/ref/tags/' + tag) with tempfile.TemporaryDirectory(prefix='mpkg-release-') as temporary: diff --git a/rust-sdk/tests/test_release.py b/rust-sdk/tests/test_release.py index dd45634..6d504a6 100644 --- a/rust-sdk/tests/test_release.py +++ b/rust-sdk/tests/test_release.py @@ -85,5 +85,44 @@ def test_credentials_and_github_tokens_do_not_enter_signer_environment(self): self.assertIn('verify', run.call_args_list[-1].args[0]) +class PublicationPolicy(unittest.TestCase): + def setUp(self): + self.policy = dict(can_admins_bypass=False, + protection_rules=[dict(type='required_reviewers', reviewers=[dict(type='Team', reviewer=dict(id=123))])], + deployment_branch_policy=dict(protected_branches=False, custom_branch_policies=True)) + self.branches = dict(total_count=1, branch_policies=[dict(type='branch', name='main')]) + + def check(self): + with patch.object(release.subprocess, 'check_output', side_effect=[json.dumps(self.policy), json.dumps(self.branches)]): + release.approval_policy('example/app', 'release', 123, 'main') + + def test_explicit_team_and_single_branch_are_required(self): + self.check() + self.policy['protection_rules'][0]['reviewers'] = [] + with self.assertRaises(ValueError): + self.check() + + def test_alternative_reviewers_and_admin_bypass_are_rejected(self): + self.policy['protection_rules'][0]['reviewers'].append(dict(type='User', reviewer=dict(id=456))) + with self.assertRaises(ValueError): + self.check() + self.policy['protection_rules'][0]['reviewers'].pop() + self.policy['can_admins_bypass'] = True + with self.assertRaises(ValueError): + self.check() + + def test_tags_wildcards_and_additional_branch_rules_are_rejected(self): + for entries in ([dict(type='tag', name='main')], [dict(type='branch', name='*')], + [dict(type='branch', name='main'), dict(type='branch', name='staging')]): + self.branches = dict(total_count=len(entries), branch_policies=entries) + with self.assertRaises(ValueError): + self.check() + + def test_api_failure_does_not_skip_policy_checks(self): + with patch.object(release.subprocess, 'check_output', side_effect=release.subprocess.CalledProcessError(1, 'gh')): + with self.assertRaises(release.subprocess.CalledProcessError): + release.approval_policy('example/app', 'release', 123, 'main') + + if __name__ == '__main__': unittest.main()