diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fccad78c..a50f48bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,16 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + release-workflow-test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Test release routing and input guards + run: python3 scripts/test-release-workflow.py module-ci: uses: tinyhumansai/.github/.github/workflows/module-ci.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97296066..dec7b857 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,16 @@ on: description: Existing version tag (vX.Y.Z) committed through a reviewed PR type: string required: true + release_mode: + description: Use the ordinary main path or reviewed-epic release admission + type: choice + options: [main, reviewed-epic] + default: main + required: true + reviewed_pr: + description: Positive canonical PR number whose merge commit the tag names (reviewed-epic mode only) + type: number + required: false push: tags: ['v*'] permissions: {} @@ -18,11 +28,15 @@ jobs: outputs: tag: ${{ steps.tag.outputs.tag }} version: ${{ steps.tag.outputs.version }} + release_mode: ${{ steps.tag.outputs.release_mode }} + reviewed_pr: ${{ steps.tag.outputs.reviewed_pr }} steps: - id: tag shell: bash env: TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + RELEASE_MODE: ${{ github.event_name == 'push' && 'main' || inputs.release_mode }} + REVIEWED_PR: ${{ inputs.reviewed_pr }} run: | set -euo pipefail [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { @@ -31,8 +45,30 @@ jobs: } echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + [[ "$RELEASE_MODE" == main || "$RELEASE_MODE" == reviewed-epic ]] || { + echo "release_mode must be main or reviewed-epic" >&2 + exit 2 + } + if [[ "$RELEASE_MODE" == reviewed-epic ]]; then + [[ "$REVIEWED_PR" =~ ^[1-9][0-9]*$ ]] || { + echo "reviewed_pr must be a positive integer in reviewed-epic mode" >&2 + exit 2 + } + else + [[ -z "$REVIEWED_PR" || "$REVIEWED_PR" == 0 ]] || { + echo "reviewed_pr is only valid in reviewed-epic mode" >&2 + exit 2 + } + REVIEWED_PR="" + fi + { + echo "release_mode=$RELEASE_MODE" + # The reusable workflow input is numeric; zero is the ordinary-path sentinel. + echo "reviewed_pr=${REVIEWED_PR:-0}" + } >> "$GITHUB_OUTPUT" module-release: needs: resolve + if: needs.resolve.outputs.release_mode == 'main' permissions: contents: write uses: tinyhumansai/.github/.github/workflows/module-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 @@ -41,3 +77,16 @@ jobs: version: ${{ needs.resolve.outputs.version }} module-crate: tinybox-module asset-name: tinybox + module-reviewed-epic-release: + needs: resolve + if: needs.resolve.outputs.release_mode == 'reviewed-epic' + permissions: + contents: write + pull-requests: read + checks: read + actions: read + uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@837d0424c7f38e4293f41c795f12ed5eb5dd7280 + with: + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} + reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr || '0') }} diff --git a/.gitignore b/.gitignore index 007b32b6..366e3323 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build output /target/ +/crates/tinybox-bus/pure-consumer/target/ **/*.rs.bk *.pdb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 848b742d..3f90423e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,3 +104,5 @@ Do not report vulnerabilities through public issues. Follow the process in ## Releasing Change the Cargo package versions through a reviewed PR. After it merges, tag that commit as vX.Y.Z and push the tag, or dispatch Release with an existing version tag. The shared release verifies the package version, builds and loads all 17 native and distro artifacts, and publishes their checksum manifest. Release does not push version bumps to main. + +The TinyBox bus also has an independent pure-consumer workspace and committed lockfile at `crates/tinybox-bus/pure-consumer/`. When changing the workspace package version or the storage metadata pin, refresh that lock with `cargo generate-lockfile --manifest-path crates/tinybox-bus/pure-consumer/Cargo.toml` and commit it with the change. CI runs the consumer with `--locked` and checks its full dependency and feature graph, so a stale proof lock blocks the reviewed release commit. diff --git a/Cargo.lock b/Cargo.lock index 0bcdbce8..949925e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,15 +83,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -412,17 +403,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -659,6 +639,95 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_casemap" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edfd120d8445de853cf53a1abec916f638f933a70a6f4bf8f7e2d7b7f884e9" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059fcda0fc8d33effc6633a8cf0d8a3c17caf7d7c91918fba9918744f501e4e8" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ignore" version = "0.4.33" @@ -774,6 +843,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" @@ -877,6 +952,17 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -910,6 +996,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "regex-automata" version = "0.4.18" @@ -1128,6 +1220,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1162,6 +1260,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tar" version = "0.4.46" @@ -1208,15 +1317,16 @@ dependencies = [ [[package]] name = "tinybox-bus" -version = "0.1.36" +version = "0.1.37" dependencies = [ "serde", "serde_json", + "tinystoragedrivers-bus 1.5.0", ] [[package]] name = "tinybox-cli" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "clap", @@ -1236,7 +1346,7 @@ dependencies = [ [[package]] name = "tinybox-core" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "serde", @@ -1247,7 +1357,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "serde_json", @@ -1260,11 +1370,12 @@ dependencies = [ [[package]] name = "tinybox-drivers" -version = "0.1.36" +version = "0.1.37" dependencies = [ "log", "serde_json", "tempfile", + "tinybox-bus", "tinybox-core", "tinybox-jail", "tinystoragedrivers-core", @@ -1273,7 +1384,7 @@ dependencies = [ [[package]] name = "tinybox-host" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "cap-std", @@ -1288,7 +1399,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.1.36" +version = "0.1.37" dependencies = [ "landlock", "log", @@ -1302,7 +1413,7 @@ dependencies = [ [[package]] name = "tinybox-linux" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "tempfile", @@ -1313,7 +1424,7 @@ dependencies = [ [[package]] name = "tinybox-microvm" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "serde", @@ -1326,7 +1437,7 @@ dependencies = [ [[package]] name = "tinybox-module" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "log", @@ -1353,7 +1464,7 @@ dependencies = [ [[package]] name = "tinybox-ssh" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "tempfile", @@ -1367,7 +1478,7 @@ dependencies = [ [[package]] name = "tinybox-sync" -version = "0.1.36" +version = "0.1.37" dependencies = [ "async-trait", "blake3", @@ -1381,11 +1492,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.5" +version = "0.1.7" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2", @@ -1396,13 +1509,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.1.5" +version = "0.1.7" dependencies = [ "proc-macro2", "quote", @@ -1411,7 +1524,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.5" +version = "0.1.7" dependencies = [ "async-trait", "serde", @@ -1431,6 +1544,15 @@ dependencies = [ "tinybus", ] +[[package]] +name = "tinystoragedrivers-bus" +version = "1.5.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=c9ea6ff1615b77b55d7269403af5fd11bedc9ce8#c9ea6ff1615b77b55d7269403af5fd11bedc9ce8" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinystoragedrivers-busproxy" version = "0.11.0" @@ -1440,7 +1562,7 @@ dependencies = [ "serde", "serde_json", "tinybus", - "tinystoragedrivers-bus", + "tinystoragedrivers-bus 0.11.0", "tinystoragedrivers-client", "tinystoragedrivers-core", "tokio", @@ -1454,7 +1576,7 @@ dependencies = [ "async-trait", "serde", "serde_json", - "tinystoragedrivers-bus", + "tinystoragedrivers-bus 0.11.0", "tinystoragedrivers-core", "tinystoragedrivers-secrets", "tokio", @@ -1495,6 +1617,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -1604,6 +1743,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -1655,6 +1803,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1899,6 +2053,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -1909,6 +2069,50 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -1916,36 +2120,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zip" -version = "2.4.2" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror", - "zopfli", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "zmij" -version = "1.0.23" +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "zopfli" -version = "0.8.3" +name = "zerovec-derive" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", + "proc-macro2", + "quote", + "syn 3.0.3", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index edd62856..25ad9aaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ exclude = ["vendor"] [workspace.package] -version = "0.1.36" +version = "0.1.37" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" @@ -37,13 +37,13 @@ categories = ["development-tools", "virtualization"] [workspace.dependencies] # TinyBus defines the message types, interface macro, and frozen module ABI used # by the bus adapter. Socket and CLI features are unnecessary here. -tinybus = { version = ">=0.1.3, <0.2", default-features = false, features = [ +tinybus = { version = ">=0.1.7, <0.2", default-features = false, features = [ "macros", "modules", ] } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. -tinybus-module = ">=0.1.3, <0.2" +tinybus-module = ">=0.1.7, <0.2" # Derive macros for the crate-wide error type in `tinybox-core`. thiserror = "2" @@ -76,6 +76,9 @@ tinystoragedrivers-core = { version = ">=0.8.0, <1", git = "https://github.com/t tinystoragedrivers-busproxy = { version = ">=0.8.0, <1", git = "https://github.com/tinyhumansai/tinystoragedrivers", tag = "v0.11.0" } tinystoragedrivers-client = { version = ">=0.8.0, <1", git = "https://github.com/tinyhumansai/tinystoragedrivers", tag = "v0.11.0" } tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.1.32" } +# Shared schema types for TinyBox's storage declaration. Disable the default +# client feature so the data-only contract does not link TinyBus. +tinystoragedrivers-bus = { version = "1.5.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "c9ea6ff1615b77b55d7269403af5fd11bedc9ce8", default-features = false } # Lints apply to every crate in the workspace via `[lints] workspace = true`. # CI runs clippy with `-D warnings`, so anything set to "warn" here fails CI. @@ -121,3 +124,4 @@ tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } # vendored checkout so the module and the storage proxy share one `Connection`. [patch."https://github.com/tinyhumansai/tinybus"] tinybus = { path = "vendor/tinybus/crates/tinybus" } +tinybus-module = { path = "vendor/tinybus/crates/tinybus-module" } diff --git a/crates/tinybox-bus/Cargo.toml b/crates/tinybox-bus/Cargo.toml index db647e0e..d5f88fe1 100644 --- a/crates/tinybox-bus/Cargo.toml +++ b/crates/tinybox-bus/Cargo.toml @@ -8,8 +8,9 @@ license.workspace = true repository.workspace = true [dependencies] -# The bus boundary contains data only; serialization is its sole dependency. +# The bus boundary contains data only and uses the canonical storage schema. serde.workspace = true +tinystoragedrivers-bus.workspace = true [dev-dependencies] # Round-trip tests pin the serialized public vocabulary. diff --git a/crates/tinybox-bus/pure-consumer/Cargo.lock b/crates/tinybox-bus/pure-consumer/Cargo.lock new file mode 100644 index 00000000..0f49d1c0 --- /dev/null +++ b/crates/tinybox-bus/pure-consumer/Cargo.lock @@ -0,0 +1,124 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinybox-bus" +version = "0.1.37" +dependencies = [ + "serde", + "tinystoragedrivers-bus", +] + +[[package]] +name = "tinybox-bus-pure-consumer" +version = "0.1.0" +dependencies = [ + "tinybox-bus", + "tinystoragedrivers-bus", +] + +[[package]] +name = "tinystoragedrivers-bus" +version = "1.5.0" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=c9ea6ff1615b77b55d7269403af5fd11bedc9ce8#c9ea6ff1615b77b55d7269403af5fd11bedc9ce8" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/tinybox-bus/pure-consumer/Cargo.toml b/crates/tinybox-bus/pure-consumer/Cargo.toml new file mode 100644 index 00000000..c770b07a --- /dev/null +++ b/crates/tinybox-bus/pure-consumer/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tinybox-bus-pure-consumer" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +tinybox-bus = { path = ".." } +tinystoragedrivers-bus = { version = "1.5.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "c9ea6ff1615b77b55d7269403af5fd11bedc9ce8", default-features = false } diff --git a/crates/tinybox-bus/pure-consumer/src/main.rs b/crates/tinybox-bus/pure-consumer/src/main.rs new file mode 100644 index 00000000..00deceb8 --- /dev/null +++ b/crates/tinybox-bus/pure-consumer/src/main.rs @@ -0,0 +1,26 @@ +use std::collections::BTreeSet; + +use tinystoragedrivers_bus::StorageNeeds as CanonicalStorageNeeds; +use tinybox_bus::{BOX_COLLECTION, JAIL_INDEX_COLLECTION, STORAGE_NEEDS}; + +fn main() { + // This assignment fails if TinyBox and the isolated consumer resolve + // different copies of the canonical storage-bus type. + let needs: &CanonicalStorageNeeds<'static> = &STORAGE_NEEDS; + assert_eq!(needs.collections.len(), 2); + let declared: BTreeSet<&str> = needs + .collections + .iter() + .map(|collection| collection.name.as_str()) + .collect(); + let expected = BTreeSet::from([BOX_COLLECTION, JAIL_INDEX_COLLECTION]); + assert_eq!(declared, expected); + assert!(needs.blob_prefixes.is_empty()); + assert!(needs.stream_prefixes.is_empty()); + assert!(needs.secret_prefixes.is_empty()); + for collection in needs.collections { + assert!(collection.indexes.is_empty()); + assert!(collection.ttl_field.is_none()); + assert!(collection.search.is_none()); + } +} diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 7ee96142..aac28f3d 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -2,6 +2,8 @@ //! //! This crate has no bus connection, executor, native backend, or policy. //! A caller applies its own authorization before sending these requests. +//! [`STORAGE_NEEDS`] describes the collections persisted by the `TinyBox` +//! drivers; it grants no authority by itself. //! //! ``` //! use tinybox_bus::{ProcessRef, ResourceId}; @@ -47,6 +49,11 @@ pub use files::{ FileWriteInfo, FileWriteProgress, FinishFileReadRequest, FinishFileWriteRequest, ReadFileChunkRequest, WriteFileChunkRequest, }; +mod storage; +pub use storage::{ + BOX_COLLECTION, CollectionSpec, IndexSpec, JAIL_INDEX_COLLECTION, STORAGE_NEEDS, SearchSpec, + StorageNeeds, +}; /// Wire vocabulary version, independent of the release workflow's package version. /// diff --git a/crates/tinybox-bus/src/storage.rs b/crates/tinybox-bus/src/storage.rs new file mode 100644 index 00000000..5b1f9cf1 --- /dev/null +++ b/crates/tinybox-bus/src/storage.rs @@ -0,0 +1,36 @@ +//! Storage resources requested by the `TinyBox` module. + +use std::sync::LazyLock; + +pub use tinystoragedrivers_bus::{CollectionSpec, IndexSpec, SearchSpec, StorageNeeds}; + +/// Collection holding one document per box record, keyed by box id. +pub const BOX_COLLECTION: &str = "tinybox_boxes"; +/// Collection holding one index document per jail registry. +pub const JAIL_INDEX_COLLECTION: &str = "tinybox_jail_index"; + +static COLLECTIONS: LazyLock<[CollectionSpec; 2]> = LazyLock::new(|| { + [ + CollectionSpec { + name: BOX_COLLECTION.into(), + indexes: Vec::new(), + ttl_field: None, + search: None, + }, + CollectionSpec { + name: JAIL_INDEX_COLLECTION.into(), + indexes: Vec::new(), + ttl_field: None, + search: None, + }, + ] +}); + +/// Storage declarations the `TinyBox` module requests from its host. +/// +/// These metadata describe the two persisted resources used by the drivers; +/// the host still decides whether to grant them to a module instance. +pub static STORAGE_NEEDS: LazyLock> = LazyLock::new(|| StorageNeeds { + collections: &COLLECTIONS[..], + ..StorageNeeds::default() +}); diff --git a/crates/tinybox-bus/tests/storage_needs.rs b/crates/tinybox-bus/tests/storage_needs.rs new file mode 100644 index 00000000..045c7ef7 --- /dev/null +++ b/crates/tinybox-bus/tests/storage_needs.rs @@ -0,0 +1,28 @@ +//! Public storage declarations exposed by the `TinyBox` bus contract. + +use tinybox_bus::{BOX_COLLECTION, JAIL_INDEX_COLLECTION, STORAGE_NEEDS}; + +#[test] +fn storage_needs_declare_every_persisted_collection_and_no_other_resources() { + assert_eq!(BOX_COLLECTION, "tinybox_boxes"); + assert_eq!(JAIL_INDEX_COLLECTION, "tinybox_jail_index"); + assert_eq!( + STORAGE_NEEDS + .collections + .iter() + .map(|collection| collection.name.as_str()) + .collect::>(), + [BOX_COLLECTION, JAIL_INDEX_COLLECTION] + ); + assert_eq!(STORAGE_NEEDS.blob_prefixes, &[] as &[&str]); + assert_eq!(STORAGE_NEEDS.stream_prefixes, &[] as &[&str]); + assert_eq!(STORAGE_NEEDS.secret_prefixes, &[] as &[&str]); + assert!( + STORAGE_NEEDS + .collections + .iter() + .all(|collection| collection.indexes.is_empty() + && collection.ttl_field.is_none() + && collection.search.is_none()) + ); +} diff --git a/crates/tinybox-drivers/Cargo.toml b/crates/tinybox-drivers/Cargo.toml index 14874c32..fe905206 100644 --- a/crates/tinybox-drivers/Cargo.toml +++ b/crates/tinybox-drivers/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true publish = false [dependencies] +tinybox-bus.workspace = true tinybox-core.workspace = true tinybox-jail.workspace = true tinystoragedrivers-core = { workspace = true, features = ["blocking"] } diff --git a/crates/tinybox-drivers/src/box_store.rs b/crates/tinybox-drivers/src/box_store.rs index 910ea60a..7668e31e 100644 --- a/crates/tinybox-drivers/src/box_store.rs +++ b/crates/tinybox-drivers/src/box_store.rs @@ -5,12 +5,11 @@ use std::sync::Arc; use serde_json::Value; use tinybox_core::{BoxId, BoxInfo, BoxState, Error, Result, Store}; use tinystoragedrivers_core::{ - Blocking, CollectionSpec, DocumentStore, DocumentStoreExt, ErrorKind, Precondition, Query, - StorageError, Versioned, + Blocking, DocumentStore, DocumentStoreExt, ErrorKind, Precondition, Query, StorageError, + Versioned, }; -/// The collection that holds one document per box, keyed by box id. -pub const BOX_COLLECTION: &str = "tinybox_boxes"; +use crate::BOX_COLLECTION; /// How many times a read-modify-write retries when another writer wins. const CAS_ATTEMPTS: usize = 8; @@ -42,13 +41,14 @@ impl DocumentBoxStore { /// Returns [`Error::Store`] when the bridge cannot start or the backend /// refuses the collection. pub fn new(documents: Arc) -> Result { + let collection = crate::collection_spec(BOX_COLLECTION)?; let bridge = Blocking::new().map_err(|error| store_error("open", &error))?; let store = Self { documents, bridge }; let docs = Arc::clone(&store.documents); - store.run("open", async move { - docs.ensure_collection(&CollectionSpec::new(BOX_COLLECTION)) - .await - })?; + store.run( + "open", + async move { docs.ensure_collection(&collection).await }, + )?; log::debug!("[tinybox_drivers] box store ready collection={BOX_COLLECTION}"); Ok(store) } diff --git a/crates/tinybox-drivers/src/index_store.rs b/crates/tinybox-drivers/src/index_store.rs index 52a08dbf..14142fc9 100644 --- a/crates/tinybox-drivers/src/index_store.rs +++ b/crates/tinybox-drivers/src/index_store.rs @@ -5,12 +5,9 @@ use std::sync::Arc; use serde_json::Value; use tinybox_jail::IndexStore; -use tinystoragedrivers_core::{ - Blocking, CollectionSpec, DocumentStore, ErrorKind, Precondition, StorageError, -}; +use tinystoragedrivers_core::{Blocking, DocumentStore, ErrorKind, Precondition, StorageError}; -/// The collection that holds one index document per registry. -pub const JAIL_INDEX_COLLECTION: &str = "tinybox_jail_index"; +use crate::JAIL_INDEX_COLLECTION; /// A jail registry's index as one document, `tinybox_jail_index/`. /// @@ -41,13 +38,12 @@ impl DocumentIndexStore { /// Returns an error when the bridge cannot start or the backend refuses /// the collection. pub fn new(documents: Arc, registry: impl Into) -> io::Result { + let collection = crate::collection_spec(JAIL_INDEX_COLLECTION) + .map_err(|error| io::Error::other(error.to_string()))?; let bridge = Blocking::new().map_err(|e| to_io(&e))?; let docs = Arc::clone(&documents); bridge - .run(async move { - docs.ensure_collection(&CollectionSpec::new(JAIL_INDEX_COLLECTION)) - .await - }) + .run(async move { docs.ensure_collection(&collection).await }) .and_then(|inner| inner) .map_err(|e| to_io(&e))?; Ok(Self { diff --git a/crates/tinybox-drivers/src/lib.rs b/crates/tinybox-drivers/src/lib.rs index d9d60cb4..c5e1b894 100644 --- a/crates/tinybox-drivers/src/lib.rs +++ b/crates/tinybox-drivers/src/lib.rs @@ -12,6 +12,7 @@ //! Both traits are synchronous, so each store owns a [`Blocking`] bridge that //! runs the port's futures on a dedicated runtime thread. That works from inside //! or outside a tokio runtime. +//! The collection schemas come from [`tinybox_bus::STORAGE_NEEDS`]. //! //! [`DocumentStore`]: tinystoragedrivers_core::DocumentStore //! [`Blocking`]: tinystoragedrivers_core::Blocking @@ -19,10 +20,54 @@ mod box_store; mod index_store; -pub use box_store::{BOX_COLLECTION, DocumentBoxStore}; -pub use index_store::{DocumentIndexStore, JAIL_INDEX_COLLECTION}; +pub use box_store::DocumentBoxStore; +pub use index_store::DocumentIndexStore; +pub use tinybox_bus::{BOX_COLLECTION, JAIL_INDEX_COLLECTION}; + +fn collection_spec(name: &str) -> tinybox_core::Result { + let declaration = tinybox_bus::STORAGE_NEEDS + .collections + .iter() + .find(|declaration| declaration.name == name) + .ok_or_else(|| tinybox_core::Error::Store { + operation: "open", + message: format!("storage needs do not declare collection `{name}`"), + })?; + + Ok(to_driver_collection_spec(declaration)) +} + +fn to_driver_collection_spec( + declaration: &tinybox_bus::CollectionSpec, +) -> tinystoragedrivers_core::CollectionSpec { + tinystoragedrivers_core::CollectionSpec { + name: declaration.name.clone(), + indexes: declaration + .indexes + .iter() + .map(|index| { + let spec = tinystoragedrivers_core::IndexSpec::new( + index.name.clone(), + index.fields.clone(), + ); + if index.unique { spec.unique() } else { spec } + }) + .collect(), + ttl_field: declaration.ttl_field.clone(), + search: declaration + .search + .as_ref() + .map(|search| tinystoragedrivers_core::SearchSpec { + fields: search.fields.clone(), + }), + } +} #[cfg(test)] #[path = "faulty_tests.rs"] #[allow(clippy::unwrap_used, clippy::expect_used)] mod faulty; + +#[cfg(test)] +#[path = "storage_needs_tests.rs"] +mod storage_needs_tests; diff --git a/crates/tinybox-drivers/src/storage_needs_tests.rs b/crates/tinybox-drivers/src/storage_needs_tests.rs new file mode 100644 index 00000000..714f1d4f --- /dev/null +++ b/crates/tinybox-drivers/src/storage_needs_tests.rs @@ -0,0 +1,74 @@ +//! The document drivers persist exactly the schemas declared by tinybox-bus. + +use super::*; +use std::sync::Arc; +use tinybox_bus::{CollectionSpec, IndexSpec, STORAGE_NEEDS, SearchSpec}; +use tinybox_core::{ + BoxId, BoxInfo, BoxSpec, BoxState, HostRef, Placement, SandboxRef, Store, WorkspaceSource, +}; +use tinybox_jail::IndexStore; +use tinystoragedrivers_core::{ + Blocking, DocumentStore, IndexSpec as DriverIndexSpec, MemoryStorage, Scope, StorageBackend, +}; + +#[test] +fn stores_create_every_collection_the_bus_declares() +-> std::result::Result<(), Box> { + let storage = MemoryStorage::new().for_scope(&Scope::local())?; + let documents: Arc = storage.documents().clone(); + let boxes = DocumentBoxStore::new(Arc::clone(&documents))?; + let index = DocumentIndexStore::new(Arc::clone(&documents), "main")?; + + let bridge = Blocking::new()?; + let record = BoxInfo::new( + BoxId::new("storage-needs")?, + BoxState::Ready, + BoxSpec::new( + Placement::new(HostRef::new("local")?, SandboxRef::new("passthrough")?), + WorkspaceSource::LocalDir("/workspace".into()), + ), + ); + boxes.insert(&record)?; + assert_eq!(boxes.get(&record.id)?, record); + + index.save(br#"{"records":{},"schema_version":1}"#)?; + assert_eq!( + index.load()?.as_deref(), + Some(br#"{"records":{},"schema_version":1}"#.as_slice()) + ); + + for declaration in STORAGE_NEEDS.collections { + let required = to_driver_collection_spec(declaration); + let documents = Arc::clone(&documents); + bridge.run(async move { documents.validate_collection_spec(&required).await })??; + } + Ok(()) +} + +#[test] +fn conversion_preserves_indexes_expiry_and_search_fields() { + let declaration = CollectionSpec { + name: "tinybox_example".into(), + indexes: vec![IndexSpec { + name: "by_owner".into(), + fields: vec!["owner.id".into(), "state".into()], + unique: true, + }], + ttl_field: Some("expires_ms".into()), + search: Some(SearchSpec { + fields: vec!["title".into(), "description".into()], + }), + }; + + assert_eq!( + to_driver_collection_spec(&declaration), + tinystoragedrivers_core::CollectionSpec { + name: "tinybox_example".into(), + indexes: vec![DriverIndexSpec::new("by_owner", ["owner.id", "state"]).unique()], + ttl_field: Some("expires_ms".into()), + search: Some(tinystoragedrivers_core::SearchSpec { + fields: vec!["title".into(), "description".into()], + }), + } + ); +} diff --git a/docs/specs/reviewed-epic-release.md b/docs/specs/reviewed-epic-release.md new file mode 100644 index 00000000..d8eb7de9 --- /dev/null +++ b/docs/specs/reviewed-epic-release.md @@ -0,0 +1,30 @@ +# Reviewed-epic module releases + +TinyBox supports two release entry points. A pushed `v*` tag and the default +manual `main` route use the existing canonical module-release workflow and its +fixed `tinybox-module` profile. Their inputs, reusable-workflow revision, +package name, and asset profile remain unchanged. + +An operator may choose the `reviewed-epic` route in the manual Release +workflow. That route requires a positive integer canonical pull request number +whose merge commit the existing tag identifies. The workflow passes that +number to the shared reviewed-module-release workflow, which checks the +exact `tinyhumansai/tinybox` repository profile and +`epic/8056-modules-wiring` base before checking out the admitted source, then +verifies reviewed history and CI evidence before +packaging. The shared workflow is called at the fixed reviewed-release commit +`837d0424c7f38e4293f41c795f12ed5eb5dd7280`. + +The reviewed PR number is invalid on the ordinary route. Missing, zero, +negative, fractional, or nonnumeric PR values fail before either release job +runs. Push-triggered tags always select the ordinary route. The release +workflow accepts no caller-selected repository, package, or reusable-workflow +revision. Top-level permissions remain empty; each reusable job receives only +the permissions required by its route. + +The focused workflow test executes the actual inline resolve script from an +empty working directory with representative manual and tag-triggered +environments and checks route exclusivity, exact reusable-workflow pins, fixed +package profile, and permissions. The ordinary route always forwards a +numeric zero sentinel; the reviewed route requires a positive integer. CI runs +this Python test without adding another Rust build or test job. diff --git a/docs/specs/tinybox-runtime.md b/docs/specs/tinybox-runtime.md index 0515cdfd..4406408f 100644 --- a/docs/specs/tinybox-runtime.md +++ b/docs/specs/tinybox-runtime.md @@ -424,6 +424,12 @@ Adding a field to a stored type is a compatibility question: `ports` and `stdin` are `#[serde(default)]` so a store written by an earlier build still loads. Failing to read it would orphan every box a user already had. +The TinyBus module requests its persisted storage in `tinybox-bus` through the +canonical `StorageNeeds` contract. It declares `tinybox_boxes` for box records +and `tinybox_jail_index` for jail registry indexes. The document drivers use +those same collection schemas when opening their stores; the host remains +responsible for authorizing the requested resources. + Two things protect the file, and they solve different problems. Writes are **atomic** via a temporary file and a rename, so a reader never sees a partial document. Read-modify-write sequences are **locked**, because atomicity alone diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md index 8886ba7a..6d249117 100644 --- a/docs/specs/tinybus-module-release.md +++ b/docs/specs/tinybus-module-release.md @@ -32,6 +32,12 @@ digest in the adjacent allowlist. After publishing, it downloads the Ubuntu x86_64 archive through TinyBus's GitHub release API and calls `Describe` over an in-memory bus. +The standalone `tinybox-bus/pure-consumer` workspace has its own committed lock +because workspace feature unification would hide dependencies enabled by other +TinyBox crates. Refresh that lock when the workspace version or storage +metadata pin changes; CI runs the consumer with `--locked` and rejects runtime, +transport, storage implementation, or TinyBox implementation dependencies. + ## Additive resource interface `tinybox-bus` supplies the serialized vocabulary without TinyBus, Tokio, native diff --git a/scripts/check_pure_bus_consumer.py b/scripts/check_pure_bus_consumer.py new file mode 100644 index 00000000..d8c0c5d1 --- /dev/null +++ b/scripts/check_pure_bus_consumer.py @@ -0,0 +1,168 @@ +"""Fail-closed checks for TinyBox's isolated storage-needs consumer graph.""" + +from pathlib import Path +import re +import sys + +STORAGE_BUS_NAME = "tinystoragedrivers-bus" +STORAGE_BUS_VERSION = "1.5.0" +STORAGE_BUS_URL = "https://github.com/tinyhumansai/tinystoragedrivers" +STORAGE_BUS_REV = "c9ea6ff1615b77b55d7269403af5fd11bedc9ce8" +STORAGE_BUS_SOURCE = f"git+{STORAGE_BUS_URL}?rev={STORAGE_BUS_REV}#{STORAGE_BUS_REV}" +PURE_CONSUMER_NAME = "tinybox-bus-pure-consumer" + +FORBIDDEN_PACKAGES = { + "async-std", "mio", "socket2", "polling", "tokio", "tokio-util", + "smol", "axum", "hyper", "hyper-util", "http", "http-body", + "http-body-util", "reqwest", "ureq", "tower", "tower-http", + "rusqlite", "sqlite", "mongodb", "sqlx", "sled", "redb", + "rocksdb", "redis", +} + + +class ProofError(ValueError): + """The pure consumer did not resolve to the reviewed contract graph.""" + + +def _inline_value(value: str): + value = value.strip() + if value in {"true", "false"}: + return value == "true" + match = re.fullmatch(r'"([^"\\]*)"', value) + if match is None: + raise ProofError("pure consumer manifest contains an unsupported dependency value") + return match.group(1) + + +def _dependency_table(contents: str) -> dict: + lines = contents.splitlines() + try: + start = lines.index("[dependencies]") + 1 + except ValueError as error: + raise ProofError("pure consumer manifest has no direct dependencies") from error + dependencies = {} + for line in lines[start:]: + stripped = line.strip() + if stripped.startswith("["): + break + if not stripped or stripped.startswith("#"): + continue + match = re.fullmatch(r"([A-Za-z0-9_-]+)\s*=\s*\{(.*)\}\s*", stripped) + if match is None or match.group(1) in dependencies: + raise ProofError("pure consumer manifest has an unsupported or duplicate dependency entry") + values = {} + for assignment in match.group(2).split(","): + field = re.fullmatch(r"\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*", assignment) + if field is None or field.group(1) in values: + raise ProofError("pure consumer manifest has a malformed dependency table") + values[field.group(1)] = _inline_value(field.group(2)) + dependencies[match.group(1)] = values + return dependencies + + +def validate_manifest(contents: str) -> None: + """Require a direct dependency on the canonical data-only storage bus.""" + dependencies = _dependency_table(contents) + if set(dependencies) != { + "tinybox-bus", STORAGE_BUS_NAME, + }: + raise ProofError("pure consumer direct dependencies are not the exact bus pair") + if dependencies["tinybox-bus"] != {"path": ".."}: + raise ProofError("pure consumer tinybox-bus dependency is not the local bus crate") + expected = { + "version": STORAGE_BUS_VERSION, + "git": STORAGE_BUS_URL, + "rev": STORAGE_BUS_REV, + "default-features": False, + } + if dependencies[STORAGE_BUS_NAME] != expected: + raise ProofError("pure consumer storage bus dependency is not the exact data-only pin") + + +def validate_lockfile(contents: str) -> None: + """Require one lock resolution with the exact canonical source identity.""" + blocks = re.split(r"(?m)^\[\[package\]\]\s*$", contents) + if len(blocks) < 2: + raise ProofError("pure consumer lockfile has no package resolution evidence") + matches = [] + for block in blocks[1:]: + names = re.findall(r'(?m)^name\s*=\s*"([^"\\]*)"\s*$', block) + if len(names) != 1: + raise ProofError("pure consumer lockfile has malformed package resolution evidence") + if names[0] == STORAGE_BUS_NAME: + matches.append(block) + if len(matches) != 1: + raise ProofError("pure consumer lockfile must resolve exactly one storage bus package") + package = matches[0] + versions = re.findall(r'(?m)^version\s*=\s*"([^"\\]*)"\s*$', package) + sources = re.findall(r'(?m)^source\s*=\s*"([^"\\]*)"\s*$', package) + if len(versions) != 1 or len(sources) != 1: + raise ProofError("pure consumer lockfile is missing storage bus source resolution evidence") + if versions[0] != STORAGE_BUS_VERSION or sources[0] != STORAGE_BUS_SOURCE: + raise ProofError("pure consumer lockfile storage bus version and source do not match the published pin") + + +def validate_dependency_graph(contents: str, workspace_version: str) -> None: + """Check the complete normal/build Cargo tree and its activated features.""" + versions = { + PURE_CONSUMER_NAME: set(), + "tinybox-bus": set(), + STORAGE_BUS_NAME: set(), + } + lines = [line for line in contents.splitlines() if line.strip()] + if not lines: + raise ProofError("normal/build dependency graph is missing") + for line in lines: + fields = line.split(maxsplit=2) + if len(fields) < 2 or not fields[1].startswith("v"): + raise ProofError(f"normal/build dependency graph has an unrecognized package line: {line}") + name, version = fields[:2] + features = set(re.findall(r"[A-Za-z0-9_-]+", fields[2] if len(fields) == 3 else "")) + + if name == PURE_CONSUMER_NAME: + versions[name].add(version) + elif name == "tinybox-bus": + versions[name].add(version) + elif name == STORAGE_BUS_NAME: + versions[name].add(version) + if "client" in features: + raise ProofError("pure consumer unexpectedly enabled the storage bus client feature") + elif name.startswith("tinystoragedrivers-"): + raise ProofError(f"pure consumer unexpectedly depends on storage implementation crate {name}") + elif name.startswith("tinybox-") and name != PURE_CONSUMER_NAME: + raise ProofError(f"pure consumer unexpectedly depends on TinyBox implementation crate {name}") + elif name == "tinybus" or name.startswith("tinybus-"): + raise ProofError(f"pure consumer unexpectedly depends on TinyBus runtime crate {name}") + elif name in FORBIDDEN_PACKAGES: + raise ProofError(f"pure consumer unexpectedly depends on runtime, HTTP, or database crate {name}") + + expected_versions = { + PURE_CONSUMER_NAME: {"v0.1.0"}, + "tinybox-bus": {f"v{workspace_version}"}, + STORAGE_BUS_NAME: {f"v{STORAGE_BUS_VERSION}"}, + } + if versions != expected_versions: + raise ProofError( + "normal/build dependency graph is missing or duplicates TinyBox/storage bus " + "versions: " + repr(versions) + ) + + +def main() -> int: + if len(sys.argv) != 4: + print("usage: check_pure_bus_consumer.py WORKSPACE_VERSION MANIFEST LOCKFILE", file=sys.stderr) + return 2 + workspace_version, manifest_path, lock_path = sys.argv[1:] + try: + validate_manifest(Path(manifest_path).read_text(encoding="utf-8")) + validate_lockfile(Path(lock_path).read_text(encoding="utf-8")) + validate_dependency_graph(sys.stdin.read(), workspace_version) + except (OSError, ProofError) as error: + print(f"pure consumer proof refused: {error}", file=sys.stderr) + return 1 + print("pure consumer resolves only the canonical data-only storage contract") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-feature-environment.py b/scripts/test-feature-environment.py index e6eb1740..ba405a37 100644 --- a/scripts/test-feature-environment.py +++ b/scripts/test-feature-environment.py @@ -6,10 +6,46 @@ import unittest ROOT = Path(__file__).resolve().parent.parent +PURE_CONSUMER = "crates/tinybox-bus/pure-consumer/Cargo.toml" + +def workspace_version(): + in_package = False + for line in (ROOT / "Cargo.toml").read_text().splitlines(): + line = line.strip() + if line.startswith("["): + in_package = line == "[workspace.package]" + elif in_package and line.startswith("version ="): + return line.split('"')[1] + raise AssertionError("[workspace.package] has no version") + +EXPECTED_CARGO_CALLS = [ + "test --locked --workspace", + f"run --locked --manifest-path {PURE_CONSUMER} --target-dir target", + f"tree --locked --manifest-path {PURE_CONSUMER} --prefix none --format {{p}} {{f}} -e normal,build", + "test --locked --workspace --all-features", +] + +def fake_cargo(): + return '''#!/bin/sh +set -eu +[ "$(command -v setsid)" = "$FIXTURE_PREFIX/bin/setsid" ] || exit 1 +printf "%s\\n" "$*" >> "$FIXTURE_CALLS" +case "$1" in + run) exit 0 ;; + tree) + printf "%s\\n" \\ + "tinybox-bus-pure-consumer v0.1.0" \\ + "tinybox-bus v$FIXTURE_WORKSPACE_VERSION" \\ + "tinystoragedrivers-bus v1.5.0 []" + ;; + test) ;; + *) exit 2 ;; +esac +''' class FeatureEnvironmentTests(unittest.TestCase): - def test_macos_exposes_util_linux_before_running_both_test_suites(self): - """The keg-only setsid must be on PATH for every Cargo test invocation.""" + def test_macos_exposes_util_linux_before_all_feature_checks(self): + """The keg-only setsid must be on PATH for every Cargo invocation.""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) tools = root / "tools" @@ -21,7 +57,7 @@ def test_macos_exposes_util_linux_before_running_both_test_suites(self): fixtures = { "brew": '#!/bin/sh\ncase "$1" in\ninstall) exit 0 ;;\n--prefix) printf "%s\\n" "$FIXTURE_PREFIX" ;;\n*) exit 1 ;;\nesac\n', "python3": "#!/bin/sh\nexit 0\n", - "cargo": '#!/bin/sh\n[ "$(command -v setsid)" = "$FIXTURE_PREFIX/bin/setsid" ] || exit 1\nprintf "%s\\n" "$*" >> "$FIXTURE_CALLS"\n', + "cargo": fake_cargo(), } for name, source in fixtures.items(): path = tools / name @@ -29,15 +65,15 @@ def test_macos_exposes_util_linux_before_running_both_test_suites(self): path.chmod(0o755) calls = root / "calls" env = dict(os.environ, RUNNER_OS="macOS", FIXTURE_PREFIX=str(prefix), - FIXTURE_CALLS=str(calls), PATH=str(tools) + os.pathsep + "/usr/bin:/bin") + FIXTURE_CALLS=str(calls), FIXTURE_WORKSPACE_VERSION=workspace_version(), + PATH=str(tools) + os.pathsep + "/usr/bin:/bin") result = subprocess.run(["bash", str(ROOT / "scripts/test-features.sh")], env=env, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(calls.read_text().splitlines(), [ - "test --locked --workspace", "test --locked --workspace --all-features"]) + self.assertEqual(calls.read_text().splitlines(), EXPECTED_CARGO_CALLS) class WindowsFeatureEnvironmentTests(unittest.TestCase): - def test_windows_supplies_native_shell_before_running_both_test_suites(self): + def test_windows_supplies_native_shell_before_all_feature_checks(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) tools = root / "tools" @@ -48,7 +84,7 @@ def test_windows_supplies_native_shell_before_running_both_test_suites(self): "cygpath": "#!/bin/sh\n[ \"$*\" = \"-w /usr/bin\" ] || exit 1\nprintf \"%s\\n\" \"$FIXTURE_BIN\"\n", "pwsh": "#!/bin/sh\n[ \"$*\" = \"-NoProfile -File scripts/test-windows-fixtures.ps1\" ] && exit 0\n[ \"$*\" = \"-NoProfile -File scripts/prepare-windows-fixtures.ps1 -PosixBin $FIXTURE_BIN\" ] || exit 1\ntouch \"$FIXTURE_READY\"\n", "python3": "#!/bin/sh\nexit 0\n", - "cargo": "#!/bin/sh\n[ -f \"$FIXTURE_READY\" ] || exit 1\nprintf \"%s\\n\" \"$*\" >> \"$FIXTURE_CALLS\"\n", + "cargo": '#!/bin/sh\nset -eu\n[ -f "$FIXTURE_READY" ] || exit 1\nprintf "%s\\n" "$*" >> "$FIXTURE_CALLS"\ncase "$1" in\n run) exit 0 ;;\n tree) printf "%s\\n" "tinybox-bus-pure-consumer v0.1.0" "tinybox-bus v$FIXTURE_WORKSPACE_VERSION" "tinystoragedrivers-bus v1.5.0 []" ;;\n test) ;;\n *) exit 2 ;;\nesac\n', } for name, source in fixtures.items(): path = tools / name @@ -56,12 +92,12 @@ def test_windows_supplies_native_shell_before_running_both_test_suites(self): path.chmod(0o755) env = dict(os.environ, RUNNER_OS="Windows", FIXTURE_BIN="C:/Git/usr/bin", FIXTURE_READY=str(ready), FIXTURE_CALLS=str(calls), + FIXTURE_WORKSPACE_VERSION=workspace_version(), PATH=str(tools) + os.pathsep + "/usr/bin:/bin") result = subprocess.run(["bash", str(ROOT / "scripts/test-features.sh")], env=env, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(calls.read_text().splitlines(), [ - "test --locked --workspace", "test --locked --workspace --all-features"]) + self.assertEqual(calls.read_text().splitlines(), EXPECTED_CARGO_CALLS) if __name__ == "__main__": unittest.main() diff --git a/scripts/test-features.sh b/scripts/test-features.sh index e8a4237e..b663e4fa 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -16,8 +16,11 @@ case "${RUNNER_OS:-$(uname -s)}" in export PATH="/usr/bin:$PATH" ;; esac +python3 scripts/test-pure-bus-consumer.py python3 scripts/test-e2e-version.py cargo test --locked --workspace +pure_consumer=crates/tinybox-bus/pure-consumer/Cargo.toml +scripts/test-pure-bus-consumer.sh cargo test --locked --workspace --all-features case "${RUNNER_OS:-$(uname -s)}" in Windows|MINGW*|MSYS*|CYGWIN*) ;; diff --git a/scripts/test-pure-bus-consumer.py b/scripts/test-pure-bus-consumer.py new file mode 100644 index 00000000..779ac6b9 --- /dev/null +++ b/scripts/test-pure-bus-consumer.py @@ -0,0 +1,130 @@ +"""Regression tests for the isolated TinyBox storage-contract consumer proof.""" + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from check_pure_bus_consumer import ( # noqa: E402 + ProofError, + validate_dependency_graph, + validate_lockfile, + validate_manifest, +) + + +STORAGE_URL = "https://github.com/tinyhumansai/tinystoragedrivers" +STORAGE_REV = "c9ea6ff1615b77b55d7269403af5fd11bedc9ce8" +STORAGE_SOURCE = f"git+{STORAGE_URL}?rev={STORAGE_REV}#{STORAGE_REV}" + + +def manifest(storage_dependency=None): + storage_dependency = storage_dependency or { + "version": "1.5.0", + "git": STORAGE_URL, + "rev": STORAGE_REV, + "default-features": False, + } + extra_features = ', features = ["client"]' if "features" in storage_dependency else "" + return f'''[package] +name = "tinybox-bus-pure-consumer" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +tinybox-bus = {{ path = ".." }} +tinystoragedrivers-bus = {{ version = "{storage_dependency['version']}", git = "{storage_dependency['git']}", rev = "{storage_dependency['rev']}", default-features = {str(storage_dependency['default-features']).lower()}{extra_features} }} +''' + + +def lockfile(version="1.5.0", source=STORAGE_SOURCE): + return f'''version = 4 + +[[package]] +name = "tinybox-bus-pure-consumer" +version = "0.1.0" +dependencies = [ + "tinystoragedrivers-bus", + "tinybox-bus", +] + +[[package]] +name = "tinystoragedrivers-bus" +version = "{version}" +source = "{source}" +''' + + +VALID_TREE = """tinybox-bus-pure-consumer v0.1.0 +tinybox-bus v0.1.37 [default] +tinystoragedrivers-bus v1.5.0 [] +serde v1.0.0 [default, derive, std] +""" + + +class PureBusConsumerProofTests(unittest.TestCase): + def test_accepts_exact_published_dependency_and_normal_build_feature_graph(self): + validate_manifest(manifest()) + validate_lockfile(lockfile()) + validate_dependency_graph(VALID_TREE, "0.1.37") + + def test_rejects_old_published_release_even_when_it_was_the_previous_expected_pin(self): + with self.assertRaisesRegex(ProofError, "version and source"): + validate_lockfile(lockfile("1.3.0", "git+" + STORAGE_URL + "?rev=08caa16d1d4e3c6b8d649e4f26fe1a426ee1d168#08caa16d1d4e3c6b8d649e4f26fe1a426ee1d168")) + + def test_rejects_wrong_revision_with_the_correct_version_and_url(self): + wrong_rev = "a" * 40 + with self.assertRaisesRegex(ProofError, "version and source"): + validate_lockfile(lockfile("1.5.0", f"git+{STORAGE_URL}?rev={wrong_rev}#{wrong_rev}")) + + def test_rejects_missing_or_duplicate_lock_resolution_evidence(self): + with self.assertRaisesRegex(ProofError, "resolution evidence"): + validate_lockfile("version = 4\n") + duplicate_package = lockfile().split("[[package]]\n")[2] + duplicated = lockfile() + "\n[[package]]\n" + duplicate_package + with self.assertRaisesRegex(ProofError, "exactly one"): + validate_lockfile(duplicated) + + def test_rejects_noncanonical_or_featureful_direct_manifest_dependency(self): + for dependency in ( + {"version": "1.5.0", "git": STORAGE_URL + ".git", "rev": STORAGE_REV, "default-features": False}, + {"version": "1.5.0", "git": STORAGE_URL, "rev": "08caa16d1d4e3c6b8d649e4f26fe1a426ee1d168", "default-features": False}, + {"version": "1.5.0", "git": STORAGE_URL, "rev": STORAGE_REV, "default-features": True}, + {"version": "1.5.0", "git": STORAGE_URL, "rev": STORAGE_REV, "default-features": False, "features": ["client"]}, + ): + with self.subTest(dependency=dependency), self.assertRaises(ProofError): + validate_manifest(manifest(dependency)) + + def test_rejects_client_feature_and_implementation_runtime_or_database_nodes(self): + bad_trees = ( + VALID_TREE.replace("tinystoragedrivers-bus v1.5.0 []", "tinystoragedrivers-bus v1.5.0 [client]"), + VALID_TREE + "tinystoragedrivers-client v1.5.0\n", + VALID_TREE + "tinybox-module v0.1.37\n", + VALID_TREE + "tokio v1.0.0 [rt]\n", + VALID_TREE + "sqlx v0.8.0\n", + ) + for tree in bad_trees: + with self.subTest(tree=tree), self.assertRaises(ProofError): + validate_dependency_graph(tree, "0.1.37") + + def test_rejects_missing_duplicate_or_wrong_versions_in_the_resolved_graph(self): + for tree in ( + "tinybox-bus-pure-consumer v0.1.0\ntinybox-bus v0.1.37\n", + VALID_TREE + "tinystoragedrivers-bus v1.3.0\n", + VALID_TREE + "tinybox-bus v0.1.36\n", + ): + with self.subTest(tree=tree), self.assertRaises(ProofError): + validate_dependency_graph(tree, "0.1.37") + + def test_proof_command_covers_normal_build_edges_and_enabled_features(self): + script = (ROOT / "scripts/test-pure-bus-consumer.sh").read_text() + self.assertIn("--format '{p} {f}' -e normal,build", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-pure-bus-consumer.sh b/scripts/test-pure-bus-consumer.sh new file mode 100755 index 00000000..b611df97 --- /dev/null +++ b/scripts/test-pure-bus-consumer.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +pure_consumer=crates/tinybox-bus/pure-consumer/Cargo.toml +cargo run --locked --manifest-path "$pure_consumer" --target-dir target +workspace_version="$(awk ' + /^\[workspace\.package\]$/ { in_package = 1; next } + /^\[/ { in_package = 0 } + in_package && $1 == "version" { gsub(/"/, "", $3); print $3; exit } +' Cargo.toml)" +if [[ -z "$workspace_version" ]]; then + echo "could not read the TinyBox workspace version" >&2 + exit 1 +fi + +dependency_tree="$(cargo tree --locked --manifest-path "$pure_consumer" --prefix none --format '{p} {f}' -e normal,build)" +printf '%s\n' "$dependency_tree" +python3 scripts/check_pure_bus_consumer.py \ + "$workspace_version" "$pure_consumer" crates/tinybox-bus/pure-consumer/Cargo.lock \ + <<< "$dependency_tree" diff --git a/scripts/test-release-workflow.py b/scripts/test-release-workflow.py new file mode 100644 index 00000000..c6326129 --- /dev/null +++ b/scripts/test-release-workflow.py @@ -0,0 +1,162 @@ +"""Exercise TinyBox's release routing and input guards without network access.""" + +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW = ROOT / ".github/workflows/release.yml" + + +def resolve_script(workflow): + match = re.search( + r"(?ms)^ - id: tag\n.*?^ run: \|\n((?:^ .*\n|^\n)+)", + workflow, + ) + if not match: + raise AssertionError("release resolve script was not found") + return "\n".join( + line[10:] if line.startswith(" ") else "" + for line in match[1].splitlines() + ) + + +def job_block(workflow, name): + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:|\Z)", workflow + ) + if not match: + raise AssertionError(f"release job {name} was not found") + return match[1] + + +def job_permissions(block): + match = re.search(r"(?ms)^ permissions:\n((?:^ [a-z-]+: [a-z]+\n)+)", block) + if not match: + raise AssertionError("job permissions were not found") + return dict(re.findall(r"(?m)^ ([a-z-]+): ([a-z]+)$", match[1])) + + +class ReleaseWorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.workflow = WORKFLOW.read_text() + cls.script = resolve_script(cls.workflow) + + def run_resolve(self, mode="main", reviewed_pr=""): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + env = dict( + os.environ, + TAG="v1.2.3", + RELEASE_MODE=mode, + REVIEWED_PR=reviewed_pr, + GITHUB_OUTPUT=str(output), + ) + result = subprocess.run( + ["bash", "-c", self.script], + cwd=directory, + env=env, + text=True, + capture_output=True, + ) + return result, output.read_text() if output.exists() else "" + + def test_manual_default_and_tag_push_use_the_ordinary_route(self): + self.assertIn("default: main", self.workflow) + self.assertIn("github.event_name == 'push' && 'main' || inputs.release_mode", self.workflow) + self.assertIn("github.event_name == 'push' && github.ref_name || inputs.tag", self.workflow) + result, output = self.run_resolve() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("release_mode=main", output) + self.assertIn("reviewed_pr=0", output) + + def test_resolve_script_runs_without_a_repository_checkout_as_cwd(self): + result, output = self.run_resolve() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("tag=v1.2.3", output) + self.assertIn("version=1.2.3", output) + self.assertIn("reviewed_pr=0", output) + + def test_reviewed_epic_accepts_and_forwards_positive_integer_pr(self): + result, output = self.run_resolve("reviewed-epic", "91") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("release_mode=reviewed-epic", output) + self.assertIn("reviewed_pr=91", output) + + def test_reviewed_epic_rejects_missing_zero_negative_fractional_and_text_prs(self): + for value in ("", "0", "-1", "1.5", "abc"): + with self.subTest(value=value): + result, _ = self.run_resolve("reviewed-epic", value) + self.assertEqual(result.returncode, 2) + self.assertIn("reviewed_pr must be a positive integer", result.stderr) + + def test_ordinary_route_rejects_reviewed_pr_and_emits_zero_sentinel(self): + result, _ = self.run_resolve("main", "91") + self.assertEqual(result.returncode, 2) + self.assertIn("reviewed_pr is only valid", result.stderr) + result, output = self.run_resolve("main", "0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("reviewed_pr=0", output) + + def test_unrecognized_release_mode_fails_closed(self): + result, _ = self.run_resolve("arbitrary", "") + self.assertEqual(result.returncode, 2) + self.assertIn("release_mode must be main or reviewed-epic", result.stderr) + + def test_only_fixed_package_and_reviewed_workflow_profile_are_callable(self): + self.assertIn("module-crate: tinybox-module", self.workflow) + self.assertIn("asset-name: tinybox", self.workflow) + self.assertIn( + "uses: tinyhumansai/.github/.github/workflows/module-release.yml@" + "4adc1080fb495098e7af1aade82ed7e6e3c69bb7", + self.workflow, + ) + self.assertIn( + "uses: tinyhumansai/.github/.github/workflows/module-reviewed-epic-release.yml@" + "837d0424c7f38e4293f41c795f12ed5eb5dd7280", + self.workflow, + ) + self.assertIn( + "reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr || '0') }}", + self.workflow, + ) + self.assertNotIn("inputs.package", self.workflow) + self.assertNotIn("inputs.repo", self.workflow) + + def test_reviewed_route_uses_the_pinned_canonical_admission_workflow(self): + reviewed = job_block(self.workflow, "module-reviewed-epic-release") + self.assertRegex( + reviewed, + r"(?m)^ uses: tinyhumansai/\.github/\.github/workflows/" + r"module-reviewed-epic-release\.yml@837d0424c7f38e4293f41c795f12ed5eb5dd7280$", + ) + self.assertIn( + "reviewed-pr: ${{ fromJSON(needs.resolve.outputs.reviewed_pr || '0') }}", + reviewed, + ) + self.assertNotRegex(reviewed, r"(?m)^ - uses: actions/checkout@") + + def test_release_routes_are_exclusive_and_permissions_are_minimal(self): + ordinary = job_block(self.workflow, "module-release") + reviewed = job_block(self.workflow, "module-reviewed-epic-release") + self.assertRegex(ordinary, r"(?m)^ if: needs\.resolve\.outputs\.release_mode == 'main'$") + self.assertRegex( + reviewed, + r"(?m)^ if: needs\.resolve\.outputs\.release_mode == 'reviewed-epic'$") + self.assertEqual(job_permissions(ordinary), {"contents": "write"}) + self.assertEqual(job_permissions(reviewed), { + "contents": "write", + "pull-requests": "read", + "checks": "read", + "actions": "read", + }) + self.assertRegex(self.workflow, r"(?m)^permissions: \{\}$") + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/tinybus b/vendor/tinybus index 1f33d1be..ed1ee4f5 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 1f33d1be1901a2d44bfe674cd7cf294cf3848821 +Subproject commit ed1ee4f5ee561c0320fa715eef114e0f1b888e41