mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Package prepared runtimes with the voice host (#42332)
## What changed - Add an optional `--runtime` input to `assemble_package.py`. - Validate the runtime receipt, target, source manifest, plugin inventory, paths, and file hashes before copying only the declared runtime files. - Preserve each platform's runtime layout and record the copied files and `runtime.json` in the package manifest. - Recheck hashes after copying and remove the new output if assembly fails. ## Testing Add package assembly tests for Linux, macOS, and Windows layouts, invalid receipts and paths, modified inputs, symlinked directories, and copy-time changes. GitOrigin-RevId: 8c9609af8ced406926707b2b0e1dcd6d2251f94b
This commit is contained in:
1 parent
f59905647a
commit
dc0dc4f15d
5 files changed
+363
-5
No files matched your search
@@ -24,5 +24,16 @@ an existing validated package into a fresh output and adds the helper. Supply
|
|||||||
Linux MUSL apps require same-architecture GNU helpers; other targets must match.
|
Linux MUSL apps require same-architecture GNU helpers; other targets must match.
|
||||||
The package version must end in `+<build-commit>`. The manifest records declared
|
The package version must end in `+<build-commit>`. The manifest records declared
|
||||||
build provenance and file hashes, not authentication or binary architecture proof.
|
build provenance and file hashes, not authentication or binary architecture proof.
|
||||||
Native loading, media/privacy controls and actual audio proof remain subsequent
|
Add `--runtime <prepared-runtime>` to include the platform preparer's selected
|
||||||
integration stages; this assembler does not add native runtime libraries.
|
libraries and `runtime.json` beside the helper. The assembler checks the target,
|
||||||
|
pinned source manifest, plugin list, relative paths and file hashes, then checks
|
||||||
|
the copied hashes again. It preserves `lib/` and `plugins/` on macOS, `lib/` and
|
||||||
|
`lib/gstreamer-1.0/` on Linux, and the shared `bin/` on Windows. Unlisted files are
|
||||||
|
not copied. The package manifest records every included runtime file and the
|
||||||
|
unchanged runtime receipt.
|
||||||
|
Omitting `--runtime` retains helper-only assembly.
|
||||||
|
|
||||||
|
This accepts a development runtime receipt, not an authenticated release. It
|
||||||
|
does not repeat native loader inspection or establish trust in the build inputs.
|
||||||
|
The helper still does not load these files; native loading, media/privacy controls,
|
||||||
|
linking against the prepared SDK and actual audio proof remain integration stages.
|
||||||
Vendored
+1
@@ -35,6 +35,7 @@ filegroup(
|
|||||||
"build_native.py",
|
"build_native.py",
|
||||||
"linux_runtime.py",
|
"linux_runtime.py",
|
||||||
"macos_runtime.py",
|
"macos_runtime.py",
|
||||||
|
"package_runtime.py",
|
||||||
"runtime.py",
|
"runtime.py",
|
||||||
"windows_runtime.py",
|
"windows_runtime.py",
|
||||||
":source_inputs",
|
":source_inputs",
|
||||||
|
|||||||
+45
-3
@@ -1,4 +1,4 @@
|
|||||||
"""Add a lifecycle-only helper to a fresh private copy of a canonical Codex package."""
|
"""Add a helper and optional prepared runtime to a fresh private Codex package."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -6,9 +6,23 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Import only this script's siblings, including under PYTHONSAFEPATH.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from package_runtime import runtime_files
|
||||||
|
from runtime import digest
|
||||||
|
|
||||||
|
|
||||||
def assemble(package: Path, helper: Path, voice_target: str, commit: str, output: Path):
|
def assemble(
|
||||||
|
package: Path,
|
||||||
|
helper: Path,
|
||||||
|
voice_target: str,
|
||||||
|
commit: str,
|
||||||
|
output: Path,
|
||||||
|
*,
|
||||||
|
runtime: Path | None = None,
|
||||||
|
):
|
||||||
package, helper = package.resolve(strict=True), helper.resolve(strict=True)
|
package, helper = package.resolve(strict=True), helper.resolve(strict=True)
|
||||||
output = output.absolute()
|
output = output.absolute()
|
||||||
if (
|
if (
|
||||||
@@ -57,6 +71,17 @@ def assemble(package: Path, helper: Path, voice_target: str, commit: str, output
|
|||||||
raise ValueError("helper and app entrypoint must be regular files")
|
raise ValueError("helper and app entrypoint must be regular files")
|
||||||
if not suffix and not helper.stat().st_mode & 0o111:
|
if not suffix and not helper.stat().st_mode & 0o111:
|
||||||
raise ValueError("helper is not executable")
|
raise ValueError("helper is not executable")
|
||||||
|
inputs = {}
|
||||||
|
if runtime is not None:
|
||||||
|
runtime = runtime.resolve(strict=True)
|
||||||
|
if any(parent.samefile(package) for parent in (runtime, *runtime.parents)):
|
||||||
|
raise ValueError("runtime must be outside the input package")
|
||||||
|
if any(
|
||||||
|
parent.exists() and parent.samefile(runtime)
|
||||||
|
for parent in output.resolve().parents
|
||||||
|
):
|
||||||
|
raise ValueError("output must be outside the runtime input")
|
||||||
|
inputs = runtime_files(runtime, voice_target)
|
||||||
output.mkdir() # Exclusive creation: never clean or overwrite a pre-existing output.
|
output.mkdir() # Exclusive creation: never clean or overwrite a pre-existing output.
|
||||||
try:
|
try:
|
||||||
shutil.copytree(package, output, dirs_exist_ok=True)
|
shutil.copytree(package, output, dirs_exist_ok=True)
|
||||||
@@ -64,10 +89,19 @@ def assemble(package: Path, helper: Path, voice_target: str, commit: str, output
|
|||||||
destination = output / relative_helper
|
destination = output / relative_helper
|
||||||
destination.parent.mkdir(parents=True)
|
destination.parent.mkdir(parents=True)
|
||||||
shutil.copy2(helper, destination)
|
shutil.copy2(helper, destination)
|
||||||
|
for relative, expected_digest in inputs.items():
|
||||||
|
copied = output / "codex-resources/voice" / relative
|
||||||
|
copied.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(runtime / relative, copied)
|
||||||
|
if digest(copied) != expected_digest:
|
||||||
|
raise ValueError("runtime file changed during copying")
|
||||||
digests = {}
|
digests = {}
|
||||||
for relative in (entrypoint, relative_helper):
|
for relative in (entrypoint, relative_helper):
|
||||||
with (output / relative).open("rb") as source:
|
with (output / relative).open("rb") as source:
|
||||||
digests[relative] = hashlib.file_digest(source, "sha256").hexdigest()
|
digests[relative] = hashlib.file_digest(source, "sha256").hexdigest()
|
||||||
|
digests.update(
|
||||||
|
{f"codex-resources/voice/{name}": value for name, value in inputs.items()}
|
||||||
|
)
|
||||||
manifest = {
|
manifest = {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"buildCommit": commit,
|
"buildCommit": commit,
|
||||||
@@ -91,7 +125,15 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--voice-target", required=True)
|
parser.add_argument("--voice-target", required=True)
|
||||||
parser.add_argument("--build-commit", required=True)
|
parser.add_argument("--build-commit", required=True)
|
||||||
parser.add_argument("--output", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime", type=Path, help="prepared development runtime to include unchanged"
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
assemble(
|
assemble(
|
||||||
args.package, args.helper, args.voice_target, args.build_commit, args.output
|
args.package,
|
||||||
|
args.helper,
|
||||||
|
args.voice_target,
|
||||||
|
args.build_commit,
|
||||||
|
args.output,
|
||||||
|
runtime=args.runtime,
|
||||||
)
|
)
|
||||||
Vendored
+75
@@ -0,0 +1,75 @@
|
|||||||
|
"""Validate prepared runtime files before copying them into a private package.
|
||||||
|
|
||||||
|
The runtime receipt records preparation, not authenticity. Preserve its bytes and
|
||||||
|
layout; native loader inspection remains the platform preparer's responsibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from runtime import PLUGINS, digest
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_files(root: Path, target: str) -> dict[str, str]:
|
||||||
|
manifest_path = root / "runtime.json"
|
||||||
|
if manifest_path.is_symlink() or not manifest_path.is_file():
|
||||||
|
raise ValueError("runtime manifest must be a regular file")
|
||||||
|
with manifest_path.open("rb") as source:
|
||||||
|
data = source.read(1024 * 1024 + 1)
|
||||||
|
if len(data) > 1024 * 1024:
|
||||||
|
raise ValueError("runtime manifest exceeds limit")
|
||||||
|
manifest = json.loads(data)
|
||||||
|
if (
|
||||||
|
manifest.get("schemaVersion") != 1
|
||||||
|
or manifest.get("developmentOnly") is not True
|
||||||
|
or manifest.get("target") != target
|
||||||
|
or manifest.get("sourceManifestSha256")
|
||||||
|
!= digest(Path(__file__).with_name("sources.json"))
|
||||||
|
or not re.fullmatch(r"[0-9a-f]{40}", manifest.get("sourceCommit", ""))
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"runtime receipt does not match the source inputs and helper target"
|
||||||
|
)
|
||||||
|
if target.endswith("-apple-darwin"):
|
||||||
|
pattern, plugin = (
|
||||||
|
r"(?:lib|plugins)/[A-Za-z0-9_+.-]+\.dylib",
|
||||||
|
"plugins/libgst{}.dylib",
|
||||||
|
)
|
||||||
|
elif target.endswith("-unknown-linux-gnu"):
|
||||||
|
pattern, plugin = (
|
||||||
|
r"lib/(?:gstreamer-1\.0/)?[A-Za-z0-9_+.-]+\.so(?:\.[0-9]+)*",
|
||||||
|
"lib/gstreamer-1.0/libgst{}.so",
|
||||||
|
)
|
||||||
|
elif target.endswith("-pc-windows-msvc"):
|
||||||
|
pattern, plugin = r"bin/[A-Za-z0-9_+.-]+\.[dD][lL][lL]", "bin/gst{}.dll"
|
||||||
|
else:
|
||||||
|
raise ValueError("unsupported native runtime target")
|
||||||
|
libraries = manifest.get("libraries", [])
|
||||||
|
if not 1 <= len(libraries) <= 128:
|
||||||
|
raise ValueError("unexpected runtime inventory size")
|
||||||
|
files, names = {}, set()
|
||||||
|
for record in libraries:
|
||||||
|
name, expected = record["path"], record["sha256"]
|
||||||
|
if not re.fullmatch(pattern, name) or name.casefold() in names:
|
||||||
|
raise ValueError("invalid or colliding runtime path")
|
||||||
|
path = root / name
|
||||||
|
if (
|
||||||
|
path.parent.is_symlink()
|
||||||
|
or path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or not path.resolve().is_relative_to(root)
|
||||||
|
):
|
||||||
|
raise ValueError("runtime entries must be regular files inside the input")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", expected) or digest(path) != expected:
|
||||||
|
raise ValueError("runtime file digest mismatch")
|
||||||
|
names.add(name.casefold())
|
||||||
|
files[name] = expected
|
||||||
|
plugins = sorted(plugin.format(name) for name in PLUGINS)
|
||||||
|
if sorted(manifest.get("plugins", [])) != plugins or not set(plugins).issubset(
|
||||||
|
files
|
||||||
|
):
|
||||||
|
raise ValueError("runtime must include exactly the selected plugins")
|
||||||
|
files["runtime.json"] = hashlib.sha256(data).hexdigest()
|
||||||
|
return files
|
||||||
+229
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from assemble_package import assemble
|
from assemble_package import assemble
|
||||||
|
from runtime import PLUGINS, digest
|
||||||
|
|
||||||
|
|
||||||
class AssembleTests(unittest.TestCase):
|
class AssembleTests(unittest.TestCase):
|
||||||
@@ -36,6 +41,230 @@ class AssembleTests(unittest.TestCase):
|
|||||||
self.helper.chmod(0o755)
|
self.helper.chmod(0o755)
|
||||||
self.output = self.root / "installed copy"
|
self.output = self.root / "installed copy"
|
||||||
|
|
||||||
|
def make_runtime(
|
||||||
|
self, target="aarch64-unknown-linux-gnu", plugin="lib/gstreamer-1.0/libgst{}.so"
|
||||||
|
):
|
||||||
|
root = self.root / target
|
||||||
|
root.mkdir()
|
||||||
|
libraries = []
|
||||||
|
for name in PLUGINS:
|
||||||
|
path = root / plugin.format(name)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(f"prepared {name}".encode())
|
||||||
|
libraries.append(
|
||||||
|
{"path": path.relative_to(root).as_posix(), "sha256": digest(path)}
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"developmentOnly": True,
|
||||||
|
"target": target,
|
||||||
|
"sourceCommit": "b" * 40,
|
||||||
|
"sourceManifestSha256": digest(Path(__file__).with_name("sources.json")),
|
||||||
|
"plugins": [record["path"] for record in libraries],
|
||||||
|
"libraries": libraries,
|
||||||
|
}
|
||||||
|
(root / "runtime.json").write_text(json.dumps(manifest))
|
||||||
|
return root, manifest
|
||||||
|
|
||||||
|
def test_cli_packages_each_runtime_layout_without_changing_bytes(self):
|
||||||
|
for target, plugin in (
|
||||||
|
("aarch64-unknown-linux-gnu", "lib/gstreamer-1.0/libgst{}.so"),
|
||||||
|
("aarch64-apple-darwin", "plugins/libgst{}.dylib"),
|
||||||
|
("aarch64-pc-windows-msvc", "bin/gst{}.dll"),
|
||||||
|
):
|
||||||
|
with self.subTest(target=target):
|
||||||
|
runtime, receipt = self.make_runtime(target, plugin)
|
||||||
|
(runtime / "unlisted-file").write_bytes(b"must not ship")
|
||||||
|
windows = target.endswith("windows-msvc")
|
||||||
|
entrypoint = "bin/codex.exe" if windows else "bin/codex"
|
||||||
|
self.metadata.update(target=target, entrypoint=entrypoint)
|
||||||
|
(self.package / entrypoint).write_bytes(b"unchanged app")
|
||||||
|
(self.package / "codex-package.json").write_text(
|
||||||
|
json.dumps(self.metadata)
|
||||||
|
)
|
||||||
|
output = self.root / (target + " packaged")
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(Path(__file__).with_name("assemble_package.py").resolve()),
|
||||||
|
"--package",
|
||||||
|
str(self.package),
|
||||||
|
"--helper",
|
||||||
|
str(self.helper),
|
||||||
|
"--voice-target",
|
||||||
|
target,
|
||||||
|
"--build-commit",
|
||||||
|
self.commit,
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
"--runtime",
|
||||||
|
str(runtime),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
cwd=self.root,
|
||||||
|
env={**os.environ, "PYTHONSAFEPATH": "1"},
|
||||||
|
)
|
||||||
|
voice = output / "codex-resources/voice"
|
||||||
|
self.assertFalse((voice / "unlisted-file").exists())
|
||||||
|
expected = {r["path"]: r["sha256"] for r in receipt["libraries"]}
|
||||||
|
expected["runtime.json"] = digest(runtime / "runtime.json")
|
||||||
|
self.assertEqual(
|
||||||
|
{name: digest(voice / name) for name in expected}, expected
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{name: digest(runtime / name) for name in expected}, expected
|
||||||
|
)
|
||||||
|
manifest = json.loads((voice / "manifest.json").read_text())
|
||||||
|
helper_name = "codex-voice-host.exe" if windows else "codex-voice-host"
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["sha256"],
|
||||||
|
{
|
||||||
|
entrypoint: digest(self.package / entrypoint),
|
||||||
|
f"codex-resources/voice/bin/{helper_name}": digest(self.helper),
|
||||||
|
**{
|
||||||
|
f"codex-resources/voice/{name}": value
|
||||||
|
for name, value in expected.items()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_invalid_runtime_receipts_before_creating_package(self):
|
||||||
|
runtime, original = self.make_runtime()
|
||||||
|
changes = [
|
||||||
|
{"target": "x86_64-unknown-linux-gnu"},
|
||||||
|
{"developmentOnly": False},
|
||||||
|
{"sourceManifestSha256": "0" * 64},
|
||||||
|
{"sourceCommit": "dev"},
|
||||||
|
{"plugins": original["plugins"][:-1]},
|
||||||
|
{"libraries": []},
|
||||||
|
{"libraries": original["libraries"] * 20},
|
||||||
|
{"libraries": original["libraries"] + [original["libraries"][0]]},
|
||||||
|
]
|
||||||
|
for name in (
|
||||||
|
"../outside.so",
|
||||||
|
"lib/../outside.so",
|
||||||
|
"bin/codex-voice-host",
|
||||||
|
"lib/evil:stream.so",
|
||||||
|
):
|
||||||
|
changes.append({"libraries": [{**original["libraries"][0], "path": name}]})
|
||||||
|
for change in changes:
|
||||||
|
with self.subTest(change=change), self.assertRaises(ValueError):
|
||||||
|
(runtime / "runtime.json").write_text(
|
||||||
|
json.dumps({**original, **change})
|
||||||
|
)
|
||||||
|
assemble(
|
||||||
|
self.package,
|
||||||
|
self.helper,
|
||||||
|
original["target"],
|
||||||
|
self.commit,
|
||||||
|
self.output,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_rejects_runtime_digest_changes_and_nested_output(self):
|
||||||
|
runtime, receipt = self.make_runtime()
|
||||||
|
nested = runtime / "new package"
|
||||||
|
with self.assertRaisesRegex(ValueError, "outside the runtime"):
|
||||||
|
assemble(
|
||||||
|
self.package,
|
||||||
|
self.helper,
|
||||||
|
receipt["target"],
|
||||||
|
self.commit,
|
||||||
|
nested,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
self.assertFalse(nested.exists())
|
||||||
|
(runtime / receipt["plugins"][0]).write_bytes(b"changed")
|
||||||
|
with self.assertRaisesRegex(ValueError, "digest mismatch"):
|
||||||
|
assemble(
|
||||||
|
self.package,
|
||||||
|
self.helper,
|
||||||
|
receipt["target"],
|
||||||
|
self.commit,
|
||||||
|
self.output,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_rejects_runtime_inside_input_package(self):
|
||||||
|
runtime, receipt = self.make_runtime()
|
||||||
|
nested = self.package / "runtime-staging"
|
||||||
|
runtime.rename(nested)
|
||||||
|
(nested / "unlisted-file").write_bytes(b"must not ship")
|
||||||
|
sources = [nested, self.package]
|
||||||
|
alias = self.package.with_name("APP") / "runtime-staging"
|
||||||
|
if alias.exists():
|
||||||
|
sources.append(alias)
|
||||||
|
for source in sources:
|
||||||
|
with (
|
||||||
|
self.subTest(source=source),
|
||||||
|
self.assertRaisesRegex(ValueError, "outside the input package"),
|
||||||
|
):
|
||||||
|
assemble(
|
||||||
|
self.package,
|
||||||
|
self.helper,
|
||||||
|
receipt["target"],
|
||||||
|
self.commit,
|
||||||
|
self.output,
|
||||||
|
runtime=source,
|
||||||
|
)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_rejects_symlinked_runtime_library_directories(self):
|
||||||
|
runtime, receipt = self.make_runtime()
|
||||||
|
outside = self.root / "outside"
|
||||||
|
(runtime / "lib/gstreamer-1.0").rename(outside)
|
||||||
|
try:
|
||||||
|
(runtime / "lib/gstreamer-1.0").symlink_to(
|
||||||
|
outside, target_is_directory=True
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
self.skipTest(f"symlink creation unavailable: {error}")
|
||||||
|
with self.assertRaisesRegex(ValueError, "regular files"):
|
||||||
|
assemble(
|
||||||
|
self.package,
|
||||||
|
self.helper,
|
||||||
|
receipt["target"],
|
||||||
|
self.commit,
|
||||||
|
self.output,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_copy_revalidation_removes_only_new_output(self):
|
||||||
|
runtime, receipt = self.make_runtime()
|
||||||
|
original_copy = shutil.copy2
|
||||||
|
for changed_name in (receipt["plugins"][0], "runtime.json"):
|
||||||
|
|
||||||
|
def changed_copy(source, destination, **kwargs):
|
||||||
|
result = original_copy(source, destination, **kwargs)
|
||||||
|
if source == runtime.resolve() / changed_name:
|
||||||
|
Path(destination).write_bytes(b"changed after validation")
|
||||||
|
return result
|
||||||
|
|
||||||
|
with (
|
||||||
|
self.subTest(changed_name=changed_name),
|
||||||
|
patch("assemble_package.shutil.copy2", changed_copy),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(ValueError, "changed during copying"):
|
||||||
|
assemble(
|
||||||
|
self.package,
|
||||||
|
self.helper,
|
||||||
|
receipt["target"],
|
||||||
|
self.commit,
|
||||||
|
self.output,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads((runtime / "runtime.json").read_text()), receipt
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.package / "bin/codex").read_bytes(), b"unchanged app"
|
||||||
|
)
|
||||||
|
|
||||||
def test_copies_app_unchanged_and_records_distinct_linux_targets(self):
|
def test_copies_app_unchanged_and_records_distinct_linux_targets(self):
|
||||||
assemble(
|
assemble(
|
||||||
self.package,
|
self.package,
|
||||||
|
|||||||
Reference in new issue
Block a user