diff --git a/.agents/manifests/implementation-foundation.yaml b/.agents/manifests/implementation-foundation.yaml index 09d785b..ee67acf 100644 --- a/.agents/manifests/implementation-foundation.yaml +++ b/.agents/manifests/implementation-foundation.yaml @@ -11,6 +11,7 @@ globs: - scripts/package_local.sh - scripts/package_smoke.sh - scripts/test_app.sh + - scripts/validate_cli_tar.mjs owner: ios-dev required_skills: - apple-doc-research @@ -36,6 +37,7 @@ allowed_paths: - scripts/package_local.sh - scripts/package_smoke.sh - scripts/test_app.sh + - scripts/validate_cli_tar.mjs - spec/** - .agents/** forbidden_paths: [] diff --git a/.agents/manifests/specs.yaml b/.agents/manifests/specs.yaml index c963d1a..6074926 100644 --- a/.agents/manifests/specs.yaml +++ b/.agents/manifests/specs.yaml @@ -54,6 +54,7 @@ allowed_paths: - Formula/** - packages/simbroker/** - scripts/package_cli.sh + - scripts/validate_cli_tar.mjs - scripts/package_cask_zip.sh - scripts/package_npm.sh - scripts/sync_homebrew_tap.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b10c613..f178c6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ # Public Node test surface. GitHub-hosted Ubuntu does not see the operator # home path and has no Simulator, so it runs docs, broker-core, skill -# ownership, and harness-adoption. Client tests run on GitHub-hosted macOS. +# ownership, and harness-adoption. Docs and client tests run on +# GitHub-hosted macOS so archive contracts exercise both GNU tar and bsdtar. # Home-path public-surface scanning belongs to `npm test` on the operator # machine, not this workflow. Do not raise job budgets to hide hung waits. name: Node tests @@ -54,6 +55,9 @@ jobs: node-version: "20" package-manager-cache: false + - name: Public docs contract + run: npm run test:docs + - name: client tests # Do not add --test-force-exit. On Node 20 it exits the process while # later describe() tests are still queued (cancelledByParent). diff --git a/docs/test/front-door.test.mjs b/docs/test/front-door.test.mjs index 06f7186..26d1ea8 100644 --- a/docs/test/front-door.test.mjs +++ b/docs/test/front-door.test.mjs @@ -6,6 +6,12 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { + assertPortableCliTarEntries as assertPortableCliTarEntriesForRoot, + parsePaxRecords, + validatePortableCliTar, +} from "../../scripts/validate_cli_tar.mjs"; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); function readRepoFile(relativePath) { @@ -22,6 +28,10 @@ const customReleaseAssetTemplates = [ "Simulator-Broker-.zip", ]; +function assertPortableCliTarEntries(entries) { + return assertPortableCliTarEntriesForRoot(entries, cliArchiveDirectory); +} + function assertExactCustomReleaseAssetInventory(markdown) { const marker = "A complete tagged Alpha has exactly four custom GitHub Release assets:"; const markerIndex = markdown.indexOf(marker); @@ -445,7 +455,7 @@ test("CHANGELOG and package.json name the Alpha version", () => { assert.ok(changelog.includes("scripts/package_cli.sh")); }); -test("public CI splits Ubuntu core suites from macOS client tests and skips the app suite", () => { +test("public CI runs docs on Ubuntu and macOS while splitting core from client tests", () => { const ci = readRepoFile(".github/workflows/ci.yml"); assertHostedNodeActionContract(ci, { @@ -459,6 +469,7 @@ test("public CI splits Ubuntu core suites from macOS client tests and skips the assert.match(ci, /timeout-minutes:\s*15/); assert.equal(ci.includes("npm run verify:public-surface"), false); assertWorkflowJobRunsExactCommand(ci, "node-ubuntu", "npm run test:docs"); + assertWorkflowJobRunsExactCommand(ci, "node-macos", "npm run test:docs"); assert.ok(ci.includes("npm run test:broker-core")); const clientTestRun = ci .split("\n") @@ -588,6 +599,7 @@ test("public PR and tagged-release docs gates fail closed when the command is mi assert.match(ci, /^ pull_request:\s*$/m); assert.match(release, /^ tags:\s*$/m); assertWorkflowJobRunsExactCommand(ci, "node-ubuntu", "npm run test:docs"); + assertWorkflowJobRunsExactCommand(ci, "node-macos", "npm run test:docs"); assertWorkflowJobRunsExactCommand(release, "release", "npm run test:docs"); assert.ok( release.indexOf("run: npm run test:docs") < release.indexOf("npm run package:cli"), @@ -595,13 +607,23 @@ test("public PR and tagged-release docs gates fail closed when the command is mi ); const ciWithoutDocs = ci.replace("run: npm run test:docs", "run: npm run test:broker-core"); + const macJob = workflowJobSection(ci, "node-macos"); + const ciWithoutMacDocs = ci.replace( + macJob, + macJob.replace("run: npm run test:docs", "run: npm run test:client"), + ); const releaseWithoutDocs = release.replace("run: npm run test:docs", "run: npm run test:broker-core"); assert.notEqual(ciWithoutDocs, ci, "negative CI fixture must remove the docs command"); + assert.notEqual(ciWithoutMacDocs, ci, "negative macOS CI fixture must remove the docs command"); assert.notEqual(releaseWithoutDocs, release, "negative release fixture must remove the docs command"); assert.throws( () => assertWorkflowJobRunsExactCommand(ciWithoutDocs, "node-ubuntu", "npm run test:docs"), /node-ubuntu must run npm run test:docs exactly once/, ); + assert.throws( + () => assertWorkflowJobRunsExactCommand(ciWithoutMacDocs, "node-macos", "npm run test:docs"), + /node-macos must run npm run test:docs exactly once/, + ); assert.throws( () => assertWorkflowJobRunsExactCommand(releaseWithoutDocs, "release", "npm run test:docs"), /release must run npm run test:docs exactly once/, @@ -787,6 +809,138 @@ test("root package stays private and package_npm.sh packs a runnable simbroker b assert.equal(fs.existsSync(path.join(installDir, "lib/node_modules/simbroker/client/test")), false); }); +function copyCliPackagingFixture(fixtureRoot) { + fs.mkdirSync(fixtureRoot, { recursive: true }); + for (const directory of ["broker-core", "client"]) { + fs.cpSync(path.join(repoRoot, directory), path.join(fixtureRoot, directory), { recursive: true }); + } + for (const relativePath of [ + "scripts/package_cli.sh", + "scripts/validate_cli_tar.mjs", + "package.json", + "LICENSE", + "CHANGELOG.md", + ]) { + const destination = path.join(fixtureRoot, relativePath); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(path.join(repoRoot, relativePath), destination); + } + fs.chmodSync(path.join(fixtureRoot, "scripts/package_cli.sh"), 0o755); +} + +function finalCliPaths(outputDir) { + const tarball = path.join(outputDir, `simulator-broker-${version}-cli.tar.gz`); + return { checksum: `${tarball}.sha256`, tarball }; +} + +function installedTarIdentity() { + const lookup = spawnSync("bash", ["-c", "command -v tar"], { encoding: "utf8" }); + assert.equal(lookup.status, 0, lookup.stderr); + const tarBinary = lookup.stdout.trim(); + const versionResult = spawnSync(tarBinary, ["--version"], { + encoding: "utf8", + env: { ...process.env, LC_ALL: "C" }, + }); + assert.equal(versionResult.status, 0, versionResult.stderr); + const firstLine = versionResult.stdout.split(/\r?\n/, 1)[0]; + assert.match(firstLine, /^(?:tar \(GNU tar\) |bsdtar )/); + return { firstLine, tarBinary }; +} + +test("SB-PKG-CLI-003 raw CLI tar validation rejects hidden metadata and host ownership", () => { + const paxRecords = parsePaxRecords( + Buffer.from("57 LIBARCHIVE.xattr.com.apple.provenance=AQIAnFJB7p5GquY\n"), + `${cliArchiveDirectory}/PaxHeader/README.md`, + ); + assert.deepEqual(paxRecords, [{ + key: "LIBARCHIVE.xattr.com.apple.provenance", + value: "AQIAnFJB7p5GquY", + }]); + + const portableEntry = { + gid: 0, + gname: "", + magic: "ustar\0", + name: `${cliArchiveDirectory}/README.md`, + paxRecords: [], + typeFlag: "0", + uid: 0, + uname: "", + version: "00", + }; + for (const invalidRoot of ["", ".", "..", "nested/root", "nested\\root", "nul\0root"]) { + assert.throws( + () => assertPortableCliTarEntriesForRoot([portableEntry], invalidRoot), + /CLI tar root/, + ); + } + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + name: `${cliArchiveDirectory}/PaxHeader/README.md`, + paxRecords, + typeFlag: "x", + }]), + /non-payload type x/, + ); + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + name: `${cliArchiveDirectory}/._README.md`, + }]), + /AppleDouble metadata/, + ); + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + gid: 20, + gname: "staff", + uid: 501, + uname: "local-builder", + }]), + /normalized uid 0/, + ); + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + magic: "ustar ", + version: " \0", + }]), + /exact POSIX USTAR magic/, + ); + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + version: " \0", + }]), + /exact POSIX USTAR version 00/, + ); + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + name: `${cliArchiveDirectory}/README.md/`, + }]), + /only CLI tar directories may have a trailing slash/, + ); + assert.throws( + () => assertPortableCliTarEntries([{ + ...portableEntry, + name: cliArchiveDirectory, + }]), + /CLI tarball root entry must be a directory/, + ); + for (const name of [ + `${cliArchiveDirectory}/../README.md`, + `${cliArchiveDirectory}/./README.md`, + `${cliArchiveDirectory}//README.md`, + ]) { + assert.throws( + () => assertPortableCliTarEntries([{ ...portableEntry, name }]), + /unsafe path segment/, + ); + } +}); + test("package_cli.sh writes a runnable CLI tarball without tests or the app", () => { const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cli-")); const fakeBin = path.join(outputDir, "fake-bin"); @@ -818,6 +972,9 @@ printf '%s\n' "$SIMBROKER_TEST_CHECKOUT_ROOT/artifacts/npm/should-not-appear.tgz const checksum = `${tarball}.sha256`; assert.equal(fs.existsSync(tarball), true, result.stdout); assert.equal(fs.existsSync(checksum), true, result.stdout); + assert.equal(fs.statSync(tarball).mode & 0o777, 0o644); + assert.equal(fs.statSync(checksum).mode & 0o777, 0o644); + validatePortableCliTar(tarball, cliArchiveDirectory); const extractDir = path.join(outputDir, "extract"); fs.mkdirSync(extractDir); @@ -832,6 +989,8 @@ printf '%s\n' "$SIMBROKER_TEST_CHECKOUT_ROOT/artifacts/npm/should-not-appear.tgz assert.equal(fs.existsSync(path.join(root, "client/test")), false); assert.equal(fs.existsSync(path.join(root, "app")), false); assert.equal(fs.existsSync(path.join(root, "LICENSE")), true); + assert.equal(fs.existsSync(path.join(root, "CHANGELOG.md")), true); + assert.equal(fs.existsSync(path.join(root, "package.json")), true); const packagedReadme = fs.readFileSync(path.join(root, "README.md"), "utf8"); assert.equal( fs.existsSync(npmInvocationSentinel), @@ -855,6 +1014,250 @@ printf '%s\n' "$SIMBROKER_TEST_CHECKOUT_ROOT/artifacts/npm/should-not-appear.tgz assert.equal(packagedReadme.includes("`./bin/simbroker --help`"), false); }); +test("package_cli.sh selects GNU tar flags even when uname reports Darwin", () => { + const script = readRepoFile("scripts/package_cli.sh"); + assert.equal(script.includes("uname -s"), false, "CLI packaging must classify tar, not the host OS"); + assert.ok(script.includes('"tar (GNU tar) "*')); + assert.ok(script.includes('"bsdtar "*')); + + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cli-gnu-tar-")); + const fakeBin = path.join(outputDir, "fake-bin"); + const tarLog = path.join(outputDir, "tar-arguments.log"); + const { tarBinary } = installedTarIdentity(); + fs.mkdirSync(fakeBin); + fs.writeFileSync( + path.join(fakeBin, "uname"), + "#!/usr/bin/env bash\nprintf '%s\\n' Darwin\n", + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(fakeBin, "tar"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "--version" ]]; then + printf '%s\n' 'tar (GNU tar) 1.35' + exit 0 +fi +for arg in "$@"; do + case "$arg" in + --no-mac-metadata|--uid|--gid|--uname|--gname) + echo "GNU tar fixture received a bsdtar-only option" >&2 + exit 2 + ;; + esac +done +printf '%s\n' "$@" >> "$SIMBROKER_TEST_TAR_LOG" +exec "$SIMBROKER_TEST_REAL_TAR" "$@" +`, + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [path.join(repoRoot, "scripts/package_cli.sh"), "--output-dir", outputDir], { + encoding: "utf8", + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SIMBROKER_TEST_REAL_TAR: tarBinary, + SIMBROKER_TEST_TAR_LOG: tarLog, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const tarArguments = fs.readFileSync(tarLog, "utf8").split(/\r?\n/); + assert.ok(tarArguments.includes("--owner=0")); + assert.ok(tarArguments.includes("--group=0")); + assert.ok(tarArguments.includes("--numeric-owner")); + assert.ok(tarArguments.includes("--no-xattrs")); + assert.equal(tarArguments.includes("--no-mac-metadata"), false); + const { checksum, tarball } = finalCliPaths(outputDir); + assert.equal(fs.existsSync(tarball), true, result.stdout); + assert.equal(fs.existsSync(checksum), true, result.stdout); + validatePortableCliTar(tarball, cliArchiveDirectory); +}); + +test("package_cli.sh rejects an unknown tar before staging and clears stale outputs", () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cli-unknown-tar-")); + const outputDir = path.join(testRoot, "output"); + const fakeBin = path.join(testRoot, "fake-bin"); + const temporaryDir = path.join(testRoot, "temporary"); + const invocationSentinel = path.join(testRoot, "tar-payload-operation"); + fs.mkdirSync(outputDir); + fs.mkdirSync(fakeBin); + fs.mkdirSync(temporaryDir); + const { checksum, tarball } = finalCliPaths(outputDir); + fs.writeFileSync(tarball, "stale tar bytes\n"); + fs.writeFileSync(checksum, "stale checksum\n"); + fs.writeFileSync( + path.join(fakeBin, "tar"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "--version" ]]; then + printf '%s\n' 'toy tar 1.0' + exit 0 +fi +: > "$SIMBROKER_TEST_TAR_SENTINEL" +exit 99 +`, + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [path.join(repoRoot, "scripts/package_cli.sh"), "--output-dir", outputDir], { + encoding: "utf8", + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SIMBROKER_TEST_TAR_SENTINEL: invocationSentinel, + TMPDIR: temporaryDir, + }, + }); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /Unsupported tar implementation; expected GNU tar or bsdtar\./); + assert.equal(result.stderr.includes(repoRoot), false, result.stderr); + assert.equal(result.stderr.includes(testRoot), false, result.stderr); + assert.equal(fs.existsSync(invocationSentinel), false, "unknown tar must not touch CLI payloads"); + assert.equal(fs.readdirSync(temporaryDir).length, 0, "unknown tar must fail before staging"); + assert.equal(fs.existsSync(tarball), false); + assert.equal(fs.existsSync(checksum), false); +}); + +test("package_cli.sh rejects a real source AppleDouble entry before archive or checksum", () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cli-source-appledouble-")); + const fixtureRoot = path.join(testRoot, "checkout"); + const outputDir = path.join(testRoot, "output"); + copyCliPackagingFixture(fixtureRoot); + fs.mkdirSync(outputDir); + fs.writeFileSync(path.join(fixtureRoot, "broker-core", "._codex-appledouble-probe"), "metadata\n"); + const { checksum, tarball } = finalCliPaths(outputDir); + fs.writeFileSync(tarball, "stale tar bytes\n"); + fs.writeFileSync(checksum, "stale checksum\n"); + + const result = spawnSync( + "bash", + [path.join(fixtureRoot, "scripts/package_cli.sh"), "--output-dir", outputDir], + { encoding: "utf8", cwd: fixtureRoot }, + ); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /Refusing to package AppleDouble metadata under broker-core\./); + assert.equal(result.stderr.includes(fixtureRoot), false, result.stderr); + assert.equal(result.stderr.includes(testRoot), false, result.stderr); + assert.equal(fs.existsSync(tarball), false); + assert.equal(fs.existsSync(checksum), false); + assert.deepEqual(fs.readdirSync(outputDir), []); +}); + +test("package_cli.sh rejects AppleDouble metadata synthesized while staging", () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cli-staged-appledouble-")); + const outputDir = path.join(testRoot, "output"); + const fakeBin = path.join(testRoot, "fake-bin"); + fs.mkdirSync(outputDir); + fs.mkdirSync(fakeBin); + const { firstLine, tarBinary } = installedTarIdentity(); + fs.writeFileSync( + path.join(fakeBin, "tar"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "--version" ]]; then + printf '%s\n' "$SIMBROKER_TEST_TAR_VERSION" + exit 0 +fi +destination='' +extract=false +previous='' +for arg in "$@"; do + if [[ "$previous" == "-C" ]]; then + destination="$arg" + fi + if [[ "$arg" == "-xf" ]]; then + extract=true + fi + previous="$arg" +done +"$SIMBROKER_TEST_REAL_TAR" "$@" +if [[ "$extract" == true && -n "$destination" ]]; then + printf '%s\n' metadata > "$destination/._codex-staged-appledouble" +fi +`, + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [path.join(repoRoot, "scripts/package_cli.sh"), "--output-dir", outputDir], { + encoding: "utf8", + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SIMBROKER_TEST_REAL_TAR: tarBinary, + SIMBROKER_TEST_TAR_VERSION: firstLine, + }, + }); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /Refusing to package AppleDouble metadata under simulator-broker-/); + assert.equal(result.stderr.includes(repoRoot), false, result.stderr); + assert.equal(result.stderr.includes(testRoot), false, result.stderr); + const { checksum, tarball } = finalCliPaths(outputDir); + assert.equal(fs.existsSync(tarball), false); + assert.equal(fs.existsSync(checksum), false); +}); + +test("package_cli.sh rejects a raw-invalid candidate before archive or checksum publication", () => { + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-package-cli-invalid-candidate-")); + const outputDir = path.join(testRoot, "output"); + const fakeBin = path.join(testRoot, "fake-bin"); + fs.mkdirSync(outputDir); + fs.mkdirSync(fakeBin); + const { checksum, tarball } = finalCliPaths(outputDir); + fs.writeFileSync(tarball, "stale tar bytes\n"); + fs.writeFileSync(checksum, "stale checksum\n"); + const { firstLine, tarBinary } = installedTarIdentity(); + fs.writeFileSync( + path.join(fakeBin, "tar"), + `#!/usr/bin/env bash +set -euo pipefail +if [[ "\${1:-}" == "--version" ]]; then + printf '%s\n' "$SIMBROKER_TEST_TAR_VERSION" + exit 0 +fi +args=("$@") +for ((index = 0; index < \${#args[@]}; index += 1)); do + if [[ "\${args[$index]}" == "-czf" ]]; then + archive="\${args[$((index + 1))]}" + printf '%s\n' 'not a tar archive' | gzip -c > "$archive" + exit 0 + fi +done +exec "$SIMBROKER_TEST_REAL_TAR" "$@" +`, + { mode: 0o755 }, + ); + + const result = spawnSync("bash", [path.join(repoRoot, "scripts/package_cli.sh"), "--output-dir", outputDir], { + encoding: "utf8", + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + SIMBROKER_TEST_REAL_TAR: tarBinary, + SIMBROKER_TEST_TAR_VERSION: firstLine, + }, + }); + + assert.notEqual(result.status, 0, result.stdout); + assert.equal( + result.stderr, + "CLI tar validation failed: archive violates the portable USTAR contract.\n", + ); + assert.equal(result.stderr.includes(repoRoot), false, result.stderr); + assert.equal(result.stderr.includes(testRoot), false, result.stderr); + assert.equal(fs.existsSync(tarball), false); + assert.equal(fs.existsSync(checksum), false); + assert.deepEqual(fs.readdirSync(outputDir), []); +}); + test("package_cask_zip.sh is the Homebrew cask zip path", () => { const script = readRepoFile("scripts/package_cask_zip.sh"); const pkg = JSON.parse(readRepoFile("package.json")); diff --git a/scripts/package_cli.sh b/scripts/package_cli.sh index f58aefd..35cf53c 100755 --- a/scripts/package_cli.sh +++ b/scripts/package_cli.sh @@ -36,33 +36,148 @@ while [[ $# -gt 0 ]]; do esac done -version="$(node -e "const fs=require('node:fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1],'utf8')).version)" "$repo_root/package.json")" +if ! version="$(node -e "const fs=require('node:fs'); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1],'utf8')).version)" "$repo_root/package.json" 2>/dev/null)"; then + echo "Unable to read the CLI package version." >&2 + exit 1 +fi if [[ -z "$version" || "$version" == "." || "$version" == ".." || "$version" == *"/"* || "$version" == *"\\"* ]]; then echo "Refusing to package an invalid package.json version: ${version:-}" >&2 exit 1 fi -stage="$(mktemp -d "${TMPDIR:-/tmp}/simbroker-package-cli.XXXXXX")" +archive_name="simulator-broker-${version}-cli" +if ! mkdir -p "$output_dir" 2>/dev/null; then + echo "Unable to prepare the CLI output directory." >&2 + exit 1 +fi +if ! output_dir="$(cd "$output_dir" 2>/dev/null && pwd -P)"; then + echo "Unable to resolve the CLI output directory." >&2 + exit 1 +fi +tarball="$output_dir/${archive_name}.tar.gz" +checksum="$output_dir/${archive_name}.tar.gz.sha256" + +# A failed rebuild must not leave older final bytes looking current. +if ! rm -f "$tarball" "$checksum" 2>/dev/null; then + echo "Unable to clear older CLI package outputs." >&2 + exit 1 +fi + +tar_binary="$(command -v tar || true)" +if [[ -z "$tar_binary" ]]; then + echo "CLI packaging requires GNU tar or bsdtar." >&2 + exit 1 +fi +if ! tar_version="$(LC_ALL=C "$tar_binary" --version 2>/dev/null)"; then + echo "Unable to identify the tar implementation; expected GNU tar or bsdtar." >&2 + exit 1 +fi +tar_version_first_line="${tar_version%%$'\n'*}" +case "$tar_version_first_line" in + "tar (GNU tar) "*) + tar_create_options=( + --format=ustar + --no-xattrs + --owner=0 + --group=0 + --numeric-owner + ) + ;; + "bsdtar "*) + tar_create_options=( + --format + ustar + --uid + 0 + --gid + 0 + --uname + '' + --gname + '' + --no-mac-metadata + --no-xattrs + ) + ;; + *) + echo "Unsupported tar implementation; expected GNU tar or bsdtar." >&2 + exit 1 + ;; +esac + +reject_appledouble() { + local scan_root="$1" + local public_label="$2" + local match + if ! match="$(find "$scan_root" -name '._*' -print -quit 2>/dev/null)"; then + echo "Unable to inspect ${public_label} for AppleDouble metadata." >&2 + return 1 + fi + if [[ -n "$match" ]]; then + echo "Refusing to package AppleDouble metadata under ${public_label}." >&2 + return 1 + fi +} + +# Source AppleDouble metadata is an input error, never something to hide. +reject_appledouble "$repo_root/broker-core" "broker-core" +reject_appledouble "$repo_root/client" "client" + +stage="" +candidate_tarball="" +candidate_checksum="" +published=false cleanup() { - rm -rf "$stage" + if [[ -n "$stage" ]]; then + rm -rf "$stage" 2>/dev/null || true + fi + if [[ -n "$candidate_tarball" ]]; then + rm -f "$candidate_tarball" 2>/dev/null || true + fi + if [[ -n "$candidate_checksum" ]]; then + rm -f "$candidate_checksum" 2>/dev/null || true + fi + if [[ "$published" != true ]]; then + rm -f "$tarball" "$checksum" 2>/dev/null || true + fi } trap cleanup EXIT -archive_name="simulator-broker-${version}-cli" +if ! stage="$(mktemp -d "${TMPDIR:-/tmp}/simbroker-package-cli.XXXXXX" 2>/dev/null)"; then + echo "Unable to create the CLI staging directory." >&2 + exit 1 +fi bundle="$stage/$archive_name" -mkdir -p "$bundle/bin" "$bundle/broker-core" "$bundle/client" +if ! mkdir -p "$bundle/bin" "$bundle/broker-core" "$bundle/client" 2>/dev/null; then + echo "Unable to prepare the CLI staging payload." >&2 + exit 1 +fi -tar -C "$repo_root/broker-core" --exclude test --exclude '*.test.mjs' -cf - . \ - | tar -C "$bundle/broker-core" -xf - -tar -C "$repo_root/client" --exclude test --exclude '*.test.mjs' -cf - . \ - | tar -C "$bundle/client" -xf - +# Prevent copyfile from synthesizing AppleDouble companions during staging. +export COPYFILE_DISABLE=1 -cp "$repo_root/package.json" "$bundle/package.json" -cp "$repo_root/LICENSE" "$bundle/LICENSE" -cp "$repo_root/CHANGELOG.md" "$bundle/CHANGELOG.md" +if ! "$tar_binary" "${tar_create_options[@]}" -C "$repo_root/broker-core" --exclude test --exclude '*.test.mjs' -cf - . 2>/dev/null \ + | "$tar_binary" -C "$bundle/broker-core" -xf - 2>/dev/null; then + echo "Failed to stage the broker-core CLI payload." >&2 + exit 1 +fi +if ! "$tar_binary" "${tar_create_options[@]}" -C "$repo_root/client" --exclude test --exclude '*.test.mjs' -cf - . 2>/dev/null \ + | "$tar_binary" -C "$bundle/client" -xf - 2>/dev/null; then + echo "Failed to stage the client CLI payload." >&2 + exit 1 +fi -{ +if ! { + cp "$repo_root/package.json" "$bundle/package.json" \ + && cp "$repo_root/LICENSE" "$bundle/LICENSE" \ + && cp "$repo_root/CHANGELOG.md" "$bundle/CHANGELOG.md" +} 2>/dev/null; then + echo "Failed to stage the CLI root payload." >&2 + exit 1 +fi + +if ! { printf '# Simulator Broker CLI %s\n\n' "$version" cat <<'EOF' Alpha CLI runtime. Node.js 20 or newer is required. Creating and running iOS @@ -78,31 +193,88 @@ This archive is the Node CLI only. Homebrew installs it through Formula/simbroker.rb. The packable npm CLI is packages/simbroker (`npm run package:npm`). The macOS operator app is separate. EOF -} > "$bundle/README.md" +} > "$bundle/README.md" 2>/dev/null; then + echo "Failed to write the CLI README payload." >&2 + exit 1 +fi -cat > "$bundle/bin/simbroker" <<'EOF' +if ! cat > "$bundle/bin/simbroker" 2>/dev/null <<'EOF' #!/usr/bin/env bash set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" exec node "$root/client/bin/simbroker.mjs" "$@" EOF -chmod +x "$bundle/bin/simbroker" +then + echo "Failed to write the CLI launcher payload." >&2 + exit 1 +fi +if ! chmod +x "$bundle/bin/simbroker" 2>/dev/null; then + echo "Failed to make the CLI launcher executable." >&2 + exit 1 +fi -mkdir -p "$output_dir" -output_dir="$(cd "$output_dir" && pwd -P)" -tarball="$output_dir/${archive_name}.tar.gz" -checksum="$output_dir/${archive_name}.tar.gz.sha256" +reject_appledouble "$bundle" "$archive_name" + +if ! candidate_tarball="$(mktemp "$output_dir/.${archive_name}.tar.gz.XXXXXX" 2>/dev/null)"; then + echo "Unable to prepare the CLI tar candidate." >&2 + exit 1 +fi +if ! "$tar_binary" "${tar_create_options[@]}" -C "$stage" -czf "$candidate_tarball" "$archive_name" 2>/dev/null; then + echo "Failed to create the CLI tar candidate." >&2 + exit 1 +fi -tar -C "$stage" -czf "$tarball" "$archive_name" +# This is the same raw-header contract imported by the docs regression. +if ! node "$repo_root/scripts/validate_cli_tar.mjs" "$candidate_tarball" "$archive_name"; then + exit 1 +fi +archive_hash="" if command -v shasum >/dev/null 2>&1; then - (cd "$output_dir" && shasum -a 256 "${archive_name}.tar.gz" > "${archive_name}.tar.gz.sha256") + if ! archive_hash="$(shasum -a 256 "$candidate_tarball" 2>/dev/null | awk '{print $1}')"; then + echo "Unable to compute the CLI tar checksum." >&2 + exit 1 + fi elif command -v sha256sum >/dev/null 2>&1; then - (cd "$output_dir" && sha256sum "${archive_name}.tar.gz" > "${archive_name}.tar.gz.sha256") + if ! archive_hash="$(sha256sum "$candidate_tarball" 2>/dev/null | awk '{print $1}')"; then + echo "Unable to compute the CLI tar checksum." >&2 + exit 1 + fi else - echo "Neither shasum nor sha256sum is available; cannot write $checksum" >&2 + echo "Neither shasum nor sha256sum is available; cannot write the CLI checksum." >&2 + exit 1 +fi +if [[ ! "$archive_hash" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Unable to compute the CLI tar checksum." >&2 + exit 1 +fi + +if ! candidate_checksum="$(mktemp "$output_dir/.${archive_name}.tar.gz.sha256.XXXXXX" 2>/dev/null)"; then + echo "Unable to prepare the CLI checksum candidate." >&2 + exit 1 +fi +if ! printf '%s %s\n' "$archive_hash" "${archive_name}.tar.gz" > "$candidate_checksum" 2>/dev/null; then + echo "Unable to write the CLI checksum candidate." >&2 + exit 1 +fi + +# Normalize both complete candidates before either final name becomes visible. +if ! chmod 0644 "$candidate_tarball" "$candidate_checksum" 2>/dev/null; then + echo "Unable to publish readable CLI artifacts." >&2 + exit 1 +fi + +if ! mv "$candidate_tarball" "$tarball" 2>/dev/null; then + echo "Unable to publish the CLI tarball." >&2 + exit 1 +fi +candidate_tarball="" +if ! mv "$candidate_checksum" "$checksum" 2>/dev/null; then + echo "Unable to publish the CLI checksum." >&2 exit 1 fi +candidate_checksum="" +published=true printf '%s\n' "$tarball" printf '%s\n' "$checksum" diff --git a/scripts/validate_cli_tar.mjs b/scripts/validate_cli_tar.mjs new file mode 100644 index 0000000..ffe8997 --- /dev/null +++ b/scripts/validate_cli_tar.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; + +const tarBlockSize = 512; + +function assertExpectedRoot(expectedRoot) { + assert.equal(typeof expectedRoot, "string", "CLI tar root must be a string"); + assert.notEqual(expectedRoot, "", "CLI tar root must not be empty"); + assert.notEqual(expectedRoot, ".", "CLI tar root must not be dot"); + assert.notEqual(expectedRoot, "..", "CLI tar root must not be dot-dot"); + assert.equal(/[\\/\0]/.test(expectedRoot), false, "CLI tar root must be one safe path segment"); +} + +function tarHeaderText(header, offset, length) { + const field = header.subarray(offset, offset + length); + const nul = field.indexOf(0); + return field.subarray(0, nul === -1 ? field.length : nul).toString("utf8"); +} + +function tarHeaderOctal(header, offset, length, fieldName, entryName) { + const value = tarHeaderText(header, offset, length).trim(); + assert.match(value, /^[0-7]+$/, `${entryName} has invalid ${fieldName} tar header value`); + return Number.parseInt(value, 8); +} + +export function parsePaxRecords(payload, entryName) { + const records = []; + let offset = 0; + + while (offset < payload.length) { + const separator = payload.indexOf(0x20, offset); + assert.notEqual(separator, -1, `${entryName} has a malformed PAX record length`); + const lengthText = payload.subarray(offset, separator).toString("ascii"); + assert.match(lengthText, /^[1-9][0-9]*$/, `${entryName} has a malformed PAX record length`); + const recordEnd = offset + Number.parseInt(lengthText, 10); + assert.ok(recordEnd <= payload.length, `${entryName} has a truncated PAX record`); + assert.equal(payload[recordEnd - 1], 0x0a, `${entryName} PAX record must end with a newline`); + + const record = payload.subarray(separator + 1, recordEnd - 1).toString("utf8"); + const equals = record.indexOf("="); + assert.ok(equals > 0, `${entryName} has a malformed PAX key/value record`); + records.push({ key: record.slice(0, equals), value: record.slice(equals + 1) }); + offset = recordEnd; + } + + return records; +} + +export function readRawTarEntries(tarball) { + const archive = gunzipSync(fs.readFileSync(tarball)); + assert.equal(archive.length % tarBlockSize, 0, "CLI tarball must contain complete 512-byte blocks"); + const entries = []; + let offset = 0; + let foundTrailer = false; + + while (offset + tarBlockSize <= archive.length) { + const header = archive.subarray(offset, offset + tarBlockSize); + if (header.every((byte) => byte === 0)) { + assert.ok( + archive.length - offset >= tarBlockSize * 2, + "CLI tarball must end with two zero blocks", + ); + assert.ok( + archive.subarray(offset).every((byte) => byte === 0), + "CLI tarball has non-zero data after its zero-block trailer", + ); + foundTrailer = true; + break; + } + + const name = tarHeaderText(header, 0, 100); + const prefix = tarHeaderText(header, 345, 155); + const entryName = prefix ? `${prefix}/${name}` : name; + const size = tarHeaderOctal(header, 124, 12, "size", entryName); + const storedChecksum = tarHeaderOctal(header, 148, 8, "checksum", entryName); + const computedChecksum = header.reduce( + (sum, byte, index) => sum + (index >= 148 && index < 156 ? 0x20 : byte), + 0, + ); + assert.equal(storedChecksum, computedChecksum, `${entryName} has an invalid tar header checksum`); + + const payloadStart = offset + tarBlockSize; + const payloadEnd = payloadStart + size; + assert.ok(payloadEnd <= archive.length, `${entryName} extends beyond the CLI tarball`); + const typeFlag = String.fromCharCode(header[156] || 0x30); + const payload = archive.subarray(payloadStart, payloadEnd); + entries.push({ + gid: tarHeaderOctal(header, 116, 8, "gid", entryName), + gname: tarHeaderText(header, 297, 32), + magic: header.subarray(257, 263).toString("latin1"), + name: entryName, + paxRecords: typeFlag === "x" || typeFlag === "g" ? parsePaxRecords(payload, entryName) : [], + typeFlag, + uid: tarHeaderOctal(header, 108, 8, "uid", entryName), + uname: tarHeaderText(header, 265, 32), + version: header.subarray(263, 265).toString("latin1"), + }); + offset = payloadStart + Math.ceil(size / tarBlockSize) * tarBlockSize; + } + + assert.equal(foundTrailer, true, "CLI tarball must end with a zero-block trailer"); + return entries; +} + +export function assertPortableCliTarEntries(entries, expectedRoot) { + assertExpectedRoot(expectedRoot); + assert.ok(entries.length > 0, "CLI tarball must contain payload entries"); + assert.equal(new Set(entries.map((entry) => entry.name)).size, entries.length, "CLI tar paths must be unique"); + + for (const entry of entries) { + assert.ok(["0", "5"].includes(entry.typeFlag), `CLI tar contains non-payload type ${entry.typeFlag}: ${entry.name}`); + assert.equal( + entry.name.endsWith("/") && entry.typeFlag !== "5", + false, + `only CLI tar directories may have a trailing slash: ${entry.name}`, + ); + const normalizedName = entry.name.endsWith("/") ? entry.name.slice(0, -1) : entry.name; + const segments = normalizedName.split("/"); + assert.equal(segments[0], expectedRoot, `CLI tar entry must stay under ${expectedRoot}: ${entry.name}`); + assert.equal( + segments.some((segment) => segment === "" || segment === "." || segment === ".."), + false, + `CLI tar contains an unsafe path segment: ${entry.name}`, + ); + assert.deepEqual(entry.paxRecords, [], `CLI tar contains PAX metadata: ${entry.name}`); + assert.equal( + segments.some((segment) => segment.startsWith("._")), + false, + `CLI tar contains AppleDouble metadata: ${entry.name}`, + ); + assert.equal(entry.magic, "ustar\0", `${entry.name} must use exact POSIX USTAR magic`); + assert.equal(entry.version, "00", `${entry.name} must use exact POSIX USTAR version 00`); + assert.equal(entry.uid, 0, `${entry.name} must have normalized uid 0`); + assert.equal(entry.gid, 0, `${entry.name} must have normalized gid 0`); + assert.equal(entry.uname, "", `${entry.name} must not expose a host user name`); + assert.equal(entry.gname, "", `${entry.name} must not expose a host group name`); + } + + const rootEntries = entries.filter( + (entry) => entry.name === expectedRoot || entry.name === `${expectedRoot}/`, + ); + assert.equal(rootEntries.length, 1, `CLI tarball must contain exactly one ${expectedRoot} root entry`); + assert.equal(rootEntries[0].typeFlag, "5", "CLI tarball root entry must be a directory"); +} + +export function validatePortableCliTar(tarball, expectedRoot) { + const entries = readRawTarEntries(tarball); + assertPortableCliTarEntries(entries, expectedRoot); + return entries; +} + +const invokedAsProgram = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedAsProgram) { + try { + assert.equal(process.argv.length, 4, "validator requires a tarball and expected root"); + validatePortableCliTar(process.argv[2], process.argv[3]); + } catch { + process.stderr.write("CLI tar validation failed: archive violates the portable USTAR contract.\n"); + process.exitCode = 1; + } +} diff --git a/spec/build-and-test.md b/spec/build-and-test.md index 4dc7285..e8bd07c 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -61,8 +61,21 @@ A first extracted implementation slice now exists: opt-in idle policy is absent; newcomer guidance names `idle status` and the human-attributed `idle enable` command without choosing a duration - `scripts/package_cli.sh` packages the Node CLI runtime into a versioned - top-level directory inside a tarball without XcodeGen or an app build; - newcomer commands invoke + top-level directory inside a portable USTAR tarball without XcodeGen or an + app build. Before staging, it classifies the selected tar from its stable + `LC_ALL=C` version banner as GNU tar or bsdtar and fails closed for any other + implementation. GNU tar receives its owner/group normalization flags; + bsdtar receives its uid/gid, blank uname/gname, macOS metadata, and xattr + flags. `COPYFILE_DISABLE=1` remains cross-platform. Source or staged + AppleDouble `._*` metadata is rejected rather than silently excluded. The + script removes any old final tarball and checksum at rebuild start, creates a + private candidate, and invokes `scripts/validate_cli_tar.mjs` to inspect raw + USTAR headers before checksum generation or final publication. Once both + hidden candidates are complete, publication sets them to mode `0644` before + either final name becomes visible so a shared output directory remains + readable. The final archive therefore contains no AppleDouble or PAX records + and normalizes every header to numeric uid/gid `0` without host user or group + names; newcomer commands invoke `./simulator-broker--cli/bin/simbroker`, not `./bin/simbroker` - `Formula/simbroker.rb` installs that GitHub Release tarball through Homebrew. `brew install fiveonecode/simulator-broker/simbroker` clones @@ -89,8 +102,9 @@ A first extracted implementation slice now exists: distribution payload instead of `Simulator Broker.app`. - public GitHub-hosted CI splits by machine: Ubuntu runs the managed-skill ownership check, `test:docs`, `test:broker-core`, and - `test:harness-adoption` with a 10-minute budget; macOS runs `test:client` - with a 15-minute budget. Neither job runs `test:app` or + `test:harness-adoption` with a 10-minute budget; macOS runs `test:docs` and + `test:client` with a 15-minute budget. Running `test:docs` on both hosts + exercises CLI packaging with GNU tar and bsdtar. Neither job runs `test:app` or `verify:public-surface`. Home-path leak scanning uses `os.homedir()` of the current machine, so each runner searches its own account home, not the operator home. Keep runner-home examples symbolic: @@ -165,17 +179,31 @@ A first extracted implementation slice now exists: The current deterministic verification contract includes implementation tests plus spec integrity. -### CLI archive README contract +### CLI archive payload contract | ID | Requirement | Verifier | | --- | --- | --- | | SB-PKG-CLI-001 | `scripts/package_cli.sh` treats static README Markdown as literal payload data and interpolates only the validated package version and derived archive directory. README command examples must never execute or contribute captured stdout while the archive is built. | `docs/test/front-door.test.mjs` `package_cli.sh writes a runnable CLI tarball without tests or the app` fake-command sentinel | | SB-PKG-CLI-002 | The README extracted from the final CLI tarball contains the literal `` `npm run package:npm` `` example and contains neither the exact packaging checkout root nor command-fixture output. | The same extracted-archive test | +| SB-PKG-CLI-003 | The final CLI gzip contains only regular-file and directory USTAR records under the single versioned root. Every raw header has numeric uid/gid `0` and empty user/group names; AppleDouble `._*`, PAX global/extended headers, and serialized xattrs are forbidden. Packaging accepts only stable GNU tar or bsdtar version identities and selects implementation-specific ownership and metadata flags. Source and staged AppleDouble paths fail before archive/checksum publication; a raw-invalid candidate also fails before publication; stale final tar/checksum files are removed at rebuild start. Failure diagnostics name only stable public payload labels. | `scripts/validate_cli_tar.mjs`, imported by `docs/test/front-door.test.mjs`; `SB-PKG-CLI-003 raw CLI tar validation rejects hidden metadata and host ownership`; the runnable package test; and the GNU-on-Darwin, unknown-tar, source/staged AppleDouble, and raw-invalid candidate regressions | +| SB-PKG-CLI-004 | After a valid archive candidate and its checksum are complete, `scripts/package_cli.sh` sets both hidden candidates to mode `0644` before either final rename. The published tarball and checksum must therefore both have mode `0644`. | `docs/test/front-door.test.mjs` `package_cli.sh writes a runnable CLI tarball without tests or the app` published-mode assertions | These are final-payload checks because a source-only public-surface scan cannot observe shell interpretation during archive generation. Any command execution, captured output, or exact checkout-root leak fails `test:docs` before release -packaging. This contract does not prohibit generic temporary-directory guidance. +packaging. The structure verifier decompresses the gzip and reads raw 512-byte +tar headers, validates their checksums, and parses any PAX body directly; +ordinary macOS tar listings and extractions are not acceptable evidence because +they can consume or hide AppleDouble and extended-attribute records. The +negative verifier covers PAX xattrs, AppleDouble paths, and non-normalized host +ownership on both macOS and Ubuntu. The production validator is the same module +imported by the tests, so package-time and regression-time raw parsing cannot +drift. Tar identity, not `uname`, selects the option set, so Homebrew GNU tar on +Darwin does not receive bsdtar-only flags; unknown tar implementations fail +before staging. Real source and synthesized staged `._*` companions are input +failures, never hidden exclusions. A raw-invalid candidate and its checksum are +not published, and failure messages do not expose checkout or build-root paths. +This contract does not prohibit generic temporary-directory guidance. ### Release asset contract @@ -237,8 +265,8 @@ Codex Autopilot is a separate origin-default-branch gate. It runs only the command in `autopilot.yml` (`npm test`). Autopilot does not read `.agents` or `WORKFLOW.md` as its contract. Missing origin `autopilot.yml` is not skip. Local full-repo validation remains `./scripts/validate.sh`. GitHub-hosted CI -stays the split Ubuntu/macOS jobs, and both public pull requests and tagged -releases run `npm run test:docs`. Pull-request CI still does not run +stays the split Ubuntu/macOS jobs. Public pull requests run `npm run test:docs` +on both hosts, and tagged releases run it before packaging. Pull-request CI still does not run `test:app` or `verify:public-surface`; the release workflow also does not run `test:app`. @@ -505,11 +533,11 @@ Add stronger profiles next for: - `./script/build_and_run.sh --telemetry` proves the app emits filterable `AppLifecycle` and `Refresh` unified logs during a live run, while `bash scripts/test_app.sh` exercises `Setup` and `Commands` events in focused app tests - the installer prints the installed CLI path, app path when an app was installed, env helper path, any current-shell PATH warning, PATH persist result, and the next command (`command -v simbroker` after persist, or `source ""` when persist is skipped) - `bash scripts/install_local.sh --cli-only` installs the CLI runtime without invoking `xcodegen` or `xcodebuild` and without requiring an app bundle -- `npm run package:cli` writes `artifacts/cli/simulator-broker--cli.tar.gz` plus a SHA-256 checksum, does not invoke XcodeGen or `xcodebuild`, and its extracted README preserves literal Markdown without executing package commands or capturing the exact checkout root +- `npm run package:cli` writes `artifacts/cli/simulator-broker--cli.tar.gz` plus a SHA-256 checksum, does not invoke XcodeGen or `xcodebuild`, emits only normalized raw USTAR payload records without macOS metadata or host ownership, and its extracted README preserves literal Markdown without executing package commands or capturing the exact checkout root - `npm run package:cask-zip` writes `artifacts/distribution/Simulator-Broker-.zip` plus a SHA-256 checksum from a Developer ID-signed, stapled `Simulator Broker.app` using `ditto -c -k --keepParent`. It verifies the sealed signature with `codesign --verify --deep --strict`, requires `CFBundleIdentifier`/`Identifier` `dev.codex.simulator-broker-app`, runs `xcrun stapler validate` before writing the zip, and does not build, sign, notarize, staple, tag, or publish - `.github/workflows/ci.yml` runs `test:docs`, `test:broker-core`, and - `test:harness-adoption` on `ubuntu-latest` (10 minutes) and `test:client` on - `macos-latest` (15 minutes). `.github/workflows/release.yml` runs + `test:harness-adoption` on `ubuntu-latest` (10 minutes), then runs `test:docs` + and `test:client` on `macos-latest` (15 minutes). `.github/workflows/release.yml` runs `test:docs` after tag/version validation and before package creation. These workflows do not run `npm run test:app`; pull-request CI does not run `npm run verify:public-surface` diff --git a/spec/project-structure.md b/spec/project-structure.md index c8a82b4..2b5abc5 100644 --- a/spec/project-structure.md +++ b/spec/project-structure.md @@ -39,7 +39,8 @@ Related: `spec/README.md`, `spec/global-simulator-broker.md`, `references/README - `scripts/` — repo-owned helper scripts including the canonical `validate.sh` full-repository gate, app generation, repo-local install, CLI-only install (`install_local.sh --cli-only`), CLI tarball packaging - (`package_cli.sh`), Homebrew cask zip (`package_cask_zip.sh`), npm CLI packing (`package_npm.sh`), Homebrew tap + (`package_cli.sh`) plus its raw USTAR validator (`validate_cli_tar.mjs`), + Homebrew cask zip (`package_cask_zip.sh`), npm CLI packing (`package_npm.sh`), Homebrew tap sync (`sync_homebrew_tap.sh`), distribution install, portable package creation, smoke verification, and harness bootstrap