From c13fae87258fce430bf0d84f65d89e0fab7b57d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:39:08 +0300 Subject: [PATCH 01/10] ci: adopt shared module CI and release contract Co-authored-by: Medulla --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 259 +++------------------------------- CONTRIBUTING.md | 4 + 3 files changed, 27 insertions(+), 238 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ae276c8..c738d04a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ permissions: jobs: module-ci: - uses: tinyhumansai/.github/.github/workflows/module-ci.yml@f4911b709333ee457da77e73b96aa9aec90d21ae + uses: tinyhumansai/.github/.github/workflows/module-ci.yml@5ad0e00d8af6e89fc516015ccb13ab114b947be6 with: coverage-script: scripts/check-coverage.sh msrv-build-targets: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a5967c4..ab7ba9b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,256 +1,41 @@ name: Release - on: workflow_dispatch: inputs: - bump: - description: Version bump to release (ignored when existing_tag is set) - type: choice - required: true - options: - - patch - - minor - - major - existing_tag: - description: >- - Build and upload artifacts for a tag that already exists, skipping the - version bump and the tagging. Recovery path for a run that tagged but - did not produce its bundles. + tag: + description: Existing version tag (vX.Y.Z) committed through a reviewed PR type: string - required: false - default: '' - -concurrency: - group: release-${{ github.ref_name }} - cancel-in-progress: false - + required: true + push: + tags: ['v*'] permissions: contents: write - +concurrency: + group: release-${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + cancel-in-progress: false jobs: - publish: - name: Version and tag - # This crate is **not published to crates.io**. It is consumed as a git - # submodule by its hosts and shipped to them as the module artifacts this - # workflow builds, so a registry upload would be a third distribution - # channel nobody reads. - # - # There used to be a `cargo publish` step here, and its failure was not - # cosmetic: `release-target` gates on this job succeeding, so a release - # would tag the commit, fail the upload, and then skip every artifact job — - # forcing a second run with `existing_tag` to build the bundles. `cargo - # package` below still runs, so a manifest that could not be packaged is - # still caught; nothing uploads it. - # - # The job id stays `publish` because several `needs:` and `needs.publish.*` - # references depend on it. - # - # Skipped entirely when re-cutting artifacts for an existing tag. - if: ${{ github.ref == 'refs/heads/main' && inputs.existing_tag == '' }} + resolve: runs-on: ubuntu-latest - environment: Production outputs: - crate_name: ${{ steps.version.outputs.crate_name }} - next_version: ${{ steps.version.outputs.next_version }} - tag: ${{ steps.version.outputs.tag }} + tag: ${{ steps.tag.outputs.tag }} + version: ${{ steps.tag.outputs.version }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - submodules: true - - - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master - with: - toolchain: stable - components: rustfmt, clippy - - - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Test - run: cargo test --all-features - - - name: Build documentation - env: - RUSTDOCFLAGS: -D warnings - run: cargo doc --no-deps --all-features - - - name: Compute next version - id: version + - id: tag shell: bash - run: | - set -euo pipefail - - metadata="$(cargo metadata --format-version 1 --no-deps)" - crate_name="$(jq -r '.packages[] | select(.name == "tinywallet") | .name' <<< "$metadata")" - current_version="$(jq -r '.packages[] | select(.name == "tinywallet") | .version' <<< "$metadata")" - if [[ -z "$current_version" || "$current_version" == "null" ]]; then - echo "Could not resolve the current crate version" >&2 - exit 1 - fi - - IFS=. read -r major minor patch <<< "$current_version" - case "${{ inputs.bump }}" in - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - patch) - patch=$((patch + 1)) - ;; - *) - echo "Unsupported bump: ${{ inputs.bump }}" >&2 - exit 1 - ;; - esac - - next_version="${major}.${minor}.${patch}" - tag="v${next_version}" - - git fetch --tags origin - if git rev-parse --verify --quiet "refs/tags/${tag}"; then - echo "Tag ${tag} already exists" >&2 - exit 1 - fi - - { - echo "crate_name=${crate_name}" - echo "current_version=${current_version}" - echo "next_version=${next_version}" - echo "tag=${tag}" - } >> "$GITHUB_OUTPUT" - - - name: Update crate version env: - CRATE_NAME: ${{ steps.version.outputs.crate_name }} - NEXT_VERSION: ${{ steps.version.outputs.next_version }} + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} run: | set -euo pipefail - bump() { - perl -0pi -e 's/(\[package\][\s\S]*?\nversion = ")[^"]+(")/$1$ENV{NEXT_VERSION}$2/' "$1" + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { + echo "tag must be v-prefixed semver" >&2 + exit 2 } - bump Cargo.toml - # The module crate is bumped in lockstep, not left behind. Its - # `cdylib` ships in an archive named for the *root* crate's version, - # so a drift here would publish `tinywallet-module-0.2.0-*.tar.gz` - # containing a library that reports 0.1.0 — with nothing to catch it. - bump crates/tinywallet-module/Cargo.toml - # And the contract crate, for the same reason and one more: every - # bundle job builds `--locked`, so a manifest this step misses leaves - # `Cargo.lock` disagreeing with the tree at the tag and *every* - # platform job fails to resolve. That produces a tagged release with - # zero artifacts, which is exactly what happened on tinyvoice v0.1.4. - bump crates/tinywallet-bus/Cargo.toml - # The implementation siblings are bumped for the same reason: - # a manifest this step misses leaves `Cargo.lock` disagreeing with the - # tree at the tag and every `--locked` bundle job fails to resolve. - # Their path dependencies carry no version requirement, so there is no - # inter-crate pin to rewrite alongside the version. - bump crates/tinywallet-crypto/Cargo.toml - bump crates/tinywallet-x402/Cargo.toml - # The wallet flows implementation, also versioned in lockstep. - bump crates/tinywallet-web3/Cargo.toml - # Refreshes every workspace member in the lockfile. `cargo update -p` - # cannot do this: these are path dependencies, and the later - # `--locked` steps fail against a stale lock. - cargo update --workspace - - - name: Commit version bump and tag - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock \ - crates/tinywallet-crypto/Cargo.toml crates/tinywallet-x402/Cargo.toml \ - crates/tinywallet-web3/Cargo.toml \ - crates/tinywallet-bus/Cargo.toml crates/tinywallet-module/Cargo.toml - git commit -m "Release ${RELEASE_TAG}" - git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - - # The pure contract is the packageable leaf. Implementation crates use - # unpublished siblings by path, which cargo package rewrites to registry - # dependencies. Verify the contract with every feature; nothing uploads. - - name: Package crate - run: cargo package --locked --package tinywallet-bus --all-features - - - name: Push release commit and tag - env: - RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - set -euo pipefail - git push origin "HEAD:${GITHUB_REF_NAME}" - git push origin "${RELEASE_TAG}" - - # What the bundles need is a tag and a version. That used to be entangled with - # a crates.io upload in one job, so a missing registry token took the module - # artifacts down with it even though nothing about them touches crates.io. - # The upload is gone now, so this job's gate means what it says: the tag - # exists and was pushed. - release-target: - name: Resolve release target - needs: publish - # `always()` is required because this job must still run when `publish` is - # *skipped* — the `existing_tag` recovery path, where the tag already - # exists. It is deliberately NOT tolerant of a `publish` *failure*: that job - # now only versions, packages and tags, and without a tag there is nothing - # for the bundles to attach to. - if: ${{ always() && (inputs.existing_tag != '' || needs.publish.result == 'success') }} - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.resolve.outputs.tag }} - next_version: ${{ steps.resolve.outputs.next_version }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Resolve the tag and version to build - id: resolve - shell: bash - env: - EXISTING_TAG: ${{ inputs.existing_tag }} - PUBLISHED_TAG: ${{ needs.publish.outputs.tag }} - PUBLISHED_VERSION: ${{ needs.publish.outputs.next_version }} - run: | - set -euo pipefail - if [[ -n "$EXISTING_TAG" ]]; then - git fetch --tags origin - git rev-parse --verify --quiet "refs/tags/${EXISTING_TAG}" >/dev/null \ - || { echo "tag ${EXISTING_TAG} does not exist" >&2; exit 1; } - tag="$EXISTING_TAG" - version="${EXISTING_TAG#v}" - else - tag="$PUBLISHED_TAG" - version="$PUBLISHED_VERSION" - fi - [[ -n "$tag" && -n "$version" ]] || { echo "could not resolve a release target" >&2; exit 1; } - { - echo "tag=${tag}" - echo "next_version=${version}" - } >> "$GITHUB_OUTPUT" - + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" module-release: - name: Build and verify module release - needs: release-target - if: ${{ always() && needs.release-target.result == 'success' }} - uses: tinyhumansai/.github/.github/workflows/module-release.yml@f4911b709333ee457da77e73b96aa9aec90d21ae + needs: resolve + uses: tinyhumansai/.github/.github/workflows/module-release.yml@5ad0e00d8af6e89fc516015ccb13ab114b947be6 with: - tag: ${{ needs.release-target.outputs.tag }} - version: ${{ needs.release-target.outputs.next_version }} + tag: ${{ needs.resolve.outputs.tag }} + version: ${{ needs.resolve.outputs.version }} module-crate: tinywallet-module - extra-packaged-files: '["docs/specs/tinybus-module.md"]' - include-distros: true - publish-crates-io: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba1d1db0..472e6b11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,3 +103,7 @@ matrix. The reusable workflow contract and supported release targets are documented in [tinyhumansai/.github](https://github.com/tinyhumansai/.github/blob/main/docs/module-ci-contract.md). + +## Releasing + +Change versions through a reviewed PR, then push a vX.Y.Z tag at the merged commit. Release can also be dispatched with an existing tag. The release workflow never pushes version bumps to main. From 61d9b898aec2785784ef7f5d1fa79b6839ec203a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:45:10 +0300 Subject: [PATCH 02/10] test(release): retarget workflow tests at the shared module pipeline The release workflow tests now assert that archives are built by the canonical module-release pipeline from the selected tag, and that tag resolution rejects invalid refs while passing valid version tags through. The previous assertions about cargo package selection, toolchain pinning, and root-library identity no longer match the workflow. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/release_workflow_tests.py | 48 +++++++++++++------------------ vendor/tinybus | 2 +- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/scripts/release_workflow_tests.py b/scripts/release_workflow_tests.py index f10c3a00..72b826ae 100644 --- a/scripts/release_workflow_tests.py +++ b/scripts/release_workflow_tests.py @@ -20,21 +20,12 @@ def setUpClass(cls): ["cargo", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], cwd=ROOT ) - def test_packaging_selects_a_contract_without_unpublished_path_dependencies(self): - command = re.search(r"run: cargo package --locked --package (\S+)", self.workflow) - self.assertIsNotNone(command) - package = next(p for p in json.loads(self.metadata)["packages"] if p["name"] == command[1]) - paths = [d["name"] for d in package["dependencies"] if d.get("path")] - self.assertEqual(paths, [], "cargo package must not resolve unpublished sibling crates from crates.io") - self.assertEqual(package["name"], "tinywallet-bus") - - def test_release_job_selects_an_explicit_rust_toolchain(self): - step = re.search( - r"- uses: dtolnay/rust-toolchain@[^\n]+\n(?P\s+with:\n(?:\s+[^\n]+\n)+)", - self.workflow, - ) - self.assertIsNotNone(step) - self.assertRegex(step["with"], r"(?m)^\s+toolchain:\s+stable\s*$") + def test_release_uses_the_canonical_module_pipeline(self): + """Every archive is built by the shared pipeline from the selected tag.""" + self.assertIn("tinyhumansai/.github/.github/workflows/module-release.yml@", self.workflow) + self.assertIn("module-crate: tinywallet-module", self.workflow) + self.assertNotIn("git push", self.workflow) + self.assertNotIn("cargo package", self.workflow) def test_module_build_accepts_versioned_host_labels_without_paths(self): result = subprocess.run( @@ -188,19 +179,20 @@ def test_module_verification_canonicalizes_a_symlinked_temp_root(self): ) self.assertEqual(result.returncode, 0, result.stderr) - def test_existing_tag_recovery_does_not_skip_bundle_job(self): - job = self.workflow.split(" module-release:\n", 1)[1] - condition = re.search(r"^ if: (.+)$", job, re.MULTILINE)[1] - self.assertIn("always()", condition, "a skipped versioning ancestor must not implicitly skip recovery bundles") - self.assertIn("needs.release-target.result == 'success'", condition, "recovery must still require successful target resolution") - - def test_release_identity_comes_from_the_root_library(self): - selectors = re.findall(r"jq -r '([^']+)' <<< \"\$metadata\"", self.workflow) - self.assertGreaterEqual(len(selectors), 2) - selected = [subprocess.check_output(["jq", "-r", selector], input=self.metadata).decode().strip() for selector in selectors[:2]] - root = next(p for p in json.loads(self.metadata)["packages"] if p["name"] == "tinywallet") - self.assertEqual(selected, [root["name"], root["version"]]) - + def test_existing_tag_resolves_without_a_version_bump_ancestor(self): + """Tag resolution rejects invalid refs and passes valid version tags through.""" + script = self.workflow.split(" run: |\n", 1)[1].split(" module-release:", 1)[0] + script = "\n".join(line[10:] for line in script.splitlines()) + with tempfile.TemporaryDirectory() as directory: + for tag, valid in [("v1.0.0", True), ("v1.0.0-beta.1", True), ("main", False), + ("v1.0.0; echo injected", False)]: + output = Path(directory) / "output" + output.write_text("") + result = subprocess.run(["bash", "-c", script], + env=dict(os.environ, TAG=tag, GITHUB_OUTPUT=str(output)), capture_output=True, text=True) + self.assertEqual(result.returncode == 0, valid, result.stderr) + if valid: + self.assertEqual(output.read_text(), "tag="+tag+"\nversion="+tag[1:]+"\n") if __name__ == "__main__": unittest.main() diff --git a/vendor/tinybus b/vendor/tinybus index cbb2eea9..356bb24f 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit cbb2eea92c57b24c6901a6f0b6445da7c5da46aa +Subproject commit 356bb24f30d7863d81569c7e1f07a7ae60feda48 From 58de4cef7f3057697c9f2c27a68e1bf2887fb67f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:53:45 +0300 Subject: [PATCH 03/10] fix(ci): preserve module release and platform verification contracts Co-authored-by: Medulla --- .github/workflows/ci.yml | 3 ++- .github/workflows/release.yml | 7 ++++--- AGENTS.md | 27 +++++++++++++-------------- scripts/build-module.ps1 | 2 +- scripts/build-module.sh | 6 +++--- scripts/test-distro.sh | 2 +- scripts/test-e2e.sh | 2 +- scripts/verify-module.sh | 2 +- 8 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c738d04a..589d22c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: concurrency: @@ -13,7 +14,7 @@ permissions: jobs: module-ci: - uses: tinyhumansai/.github/.github/workflows/module-ci.yml@5ad0e00d8af6e89fc516015ccb13ab114b947be6 + uses: tinyhumansai/.github/.github/workflows/module-ci.yml@3e87ade76b622844985d0c665a3b075313e4545c with: coverage-script: scripts/check-coverage.sh msrv-build-targets: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab7ba9b8..3676f9b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,7 @@ on: required: true push: tags: ['v*'] -permissions: - contents: write +permissions: {} concurrency: group: release-${{ github.event_name == 'push' && github.ref_name || inputs.tag }} cancel-in-progress: false @@ -34,7 +33,9 @@ jobs: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" module-release: needs: resolve - uses: tinyhumansai/.github/.github/workflows/module-release.yml@5ad0e00d8af6e89fc516015ccb13ab114b947be6 + permissions: + contents: write + uses: tinyhumansai/.github/.github/workflows/module-release.yml@3e87ade76b622844985d0c665a3b075313e4545c with: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} diff --git a/AGENTS.md b/AGENTS.md index edb958c4..0d13b5af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -248,20 +248,19 @@ explicitly declined with a reason. ## Releases -Releases run from `.github/workflows/release.yml` via a manual -`workflow_dispatch` with a `patch` / `minor` / `major` bump. The workflow -re-runs the full validation suite, computes the next version, updates -`Cargo.toml` and `Cargo.lock`, commits and tags `vX.Y.Z`, packages, pushes, and -publishes to crates.io using the `CARGO_REGISTRY_TOKEN` secret. - -Consequently: - -- Do not hand-edit the `version` field in `Cargo.toml`; the release workflow - owns it. -- Follow semantic versioning. Any change to the public surface that is not - purely additive is a breaking change and needs a major bump (pre-1.0: a minor - bump). -- The crate must be publishable at all times — `main` should always be green. +Releases use the canonical TinyHumans shared workflow. Update the package +versions and matching local dependency constraints in a PR, regenerate the +lockfiles, and merge only after the required shared CI checks pass. For a +workspace using version.workspace, change its root workspace.package version. + +Create and push a vX.Y.Z tag at the merged commit, or dispatch Release with +an existing tag input. The workflow checks that the tagged commit is on main +and that its module version matches the tag. It builds and verifies all 17 +native and distro archives before publishing the immutable GitHub release. +Release never commits or pushes version bumps to main. + +Follow semantic versioning: breaking changes require a major bump, or a minor +bump before 1.0. The module must remain packageable for every release target. ## Agent Working Agreement diff --git a/scripts/build-module.ps1 b/scripts/build-module.ps1 index 406c0044..9cf948c4 100644 --- a/scripts/build-module.ps1 +++ b/scripts/build-module.ps1 @@ -10,7 +10,7 @@ $ErrorActionPreference = 'Stop' $root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $root if ($Crate -ne 'tinywallet-module') { throw "Unsupported module crate: $Crate" } -if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { throw "Invalid module version: $Version" } +if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$') { throw "Invalid module version: $Version" } if ($HostKey -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid release host key: $HostKey" } $pathSeparators = [char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) if ([IO.Path]::IsPathRooted($OutputDir) -or $OutputDir.Split($pathSeparators, [StringSplitOptions]::None) -contains '..') { throw 'Output directory must be a relative path inside the repository' } diff --git a/scripts/build-module.sh b/scripts/build-module.sh index 4563d248..8ecd8c31 100755 --- a/scripts/build-module.sh +++ b/scripts/build-module.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" cd "$root" target_root="${CARGO_TARGET_DIR:-$root/target}" [[ "$target_root" == /* ]] || target_root="$root/$target_root" @@ -20,7 +20,7 @@ while (($#)); do *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -[[ "$crate" == tinywallet-module && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && -n "$rust_target" && "$host" =~ ^[A-Za-z0-9._-]+$ ]] || { +[[ "$crate" == tinywallet-module && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ && -n "$rust_target" && "$host" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "usage: build-module.sh --crate tinywallet-module --version VERSION --target TARGET --host HOST [--extra-files-json JSON] [--output-dir DIR]" >&2 exit 2 } @@ -44,7 +44,7 @@ jq -e 'type == "array" and all(.[]; type == "string" and test("^docs/[A-Za-z0-9. echo "extra files must be repository-local docs paths without traversal" >&2 exit 2 } -cargo build --locked --release --package "$crate" --target "$rust_target" +cargo build --locked --release --lib --package "$crate" --target "$rust_target" stage="$target_root/module-package" rm -rf "$stage" mkdir -p "$stage" "$output" diff --git a/scripts/test-distro.sh b/scripts/test-distro.sh index 167364fe..2e71fb37 100755 --- a/scripts/test-distro.sh +++ b/scripts/test-distro.sh @@ -8,7 +8,7 @@ case "$image" in *) echo "unsupported module build image: $image" >&2; exit 2 ;; esac -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" crate="" version="" rust_target="" host="" output="release-assets" extras='[]' while (($#)); do case "$1" in diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index 521ce8ea..c1cceb7d 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" cd "$root" if [[ "${RUNNER_OS:-}" == "Windows" ]]; then pwsh -NoProfile -File scripts/test-e2e.ps1 diff --git a/scripts/verify-module.sh b/scripts/verify-module.sh index 9399c529..0f9fe854 100755 --- a/scripts/verify-module.sh +++ b/scripts/verify-module.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" if [[ "${1:-}" == --archive ]]; then shift; fi archive="${1:?usage: verify-module.sh --archive }" archive="$(cd "$(dirname "$archive")" && pwd -P)/$(basename "$archive")" From 8e36265c90e2abcdd0d6b4a10ec1ebc2adf524a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:10:04 +0300 Subject: [PATCH 04/10] build(test-distro): pass host uid and gid into the container The distro test script now forwards the invoking user's uid and gid to the container so build artifacts are not left owned by root. The tinybus submodule is also bumped to a newer revision. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/test-distro.sh | 1 + vendor/tinybus | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test-distro.sh b/scripts/test-distro.sh index 2e71fb37..012a46f2 100755 --- a/scripts/test-distro.sh +++ b/scripts/test-distro.sh @@ -49,5 +49,6 @@ docker run --rm \ --env MODULE_TARGET="$rust_target" \ --env MODULE_HOST="$host" \ --env EXTRA_PACKAGED_FILES="$extras" \ + --env HOST_UID="$(id -u)" --env HOST_GID="$(id -g)" \ --env OUTPUT_DIR="${output_path#"$root"/}" \ "$image" bash /workspace/scripts/build-module-in-distro.sh diff --git a/vendor/tinybus b/vendor/tinybus index 356bb24f..0cbc48b2 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 356bb24f30d7863d81569c7e1f07a7ae60feda48 +Subproject commit 0cbc48b2f610e36bb5bdcd49fd7bb54252ccf957 From 7deac050530809b3cbbab8790f9b40aa316eb172 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:11:53 +0300 Subject: [PATCH 05/10] ci: finish shared workflow migration and portability fixes Co-authored-by: Medulla --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 589d22c6..3a6b1720 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ permissions: jobs: module-ci: - uses: tinyhumansai/.github/.github/workflows/module-ci.yml@3e87ade76b622844985d0c665a3b075313e4545c + uses: tinyhumansai/.github/.github/workflows/module-ci.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: coverage-script: scripts/check-coverage.sh msrv-build-targets: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3676f9b3..64a47bd7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: needs: resolve permissions: contents: write - uses: tinyhumansai/.github/.github/workflows/module-release.yml@3e87ade76b622844985d0c665a3b075313e4545c + uses: tinyhumansai/.github/.github/workflows/module-release.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: tag: ${{ needs.resolve.outputs.tag }} version: ${{ needs.resolve.outputs.version }} From 48cb4f9875234740e00ac28a9f6a3ecc5d1012e5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:33:24 +0300 Subject: [PATCH 06/10] chore: add compiled bytecode for bus boundary check Add the compiled Python bytecode for the bus boundary check script. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../check-bus-boundaries.cpython-314.pyc | Bin 0 -> 3349 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 scripts/__pycache__/check-bus-boundaries.cpython-314.pyc diff --git a/scripts/__pycache__/check-bus-boundaries.cpython-314.pyc b/scripts/__pycache__/check-bus-boundaries.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63263e6070b11eae81fe0a758524bd48cc17ff01 GIT binary patch literal 3349 zcmaJ@TWk~A89rl=?TI~h5+{MgEQ!NyF*6ZY&!FNrJ$2=+y)0XHW#vqRY2tY8^VyeC1_Y8mvbn5|k=25wub&joAs3V2zocVb&O4Adp&z$BzMSot0rF+5Ukg55k%GZEXRb-r1F zKsTe>M{Fkj2bGEH=te3Rv771H$0WBJoFn$I;qkYcd2139PWv)?vj@(TpaUc{+=@8D zHE&&jWu(T`tPMp2)xKC~pVOoQ`+fslhss(o!aDDbl#7NTqX#^b0{?=hOxXQHse;4g zHv(~AX=T5)m+x@ldUJPe6u5B1+RB_oRy8v@f*mKS?GS3JS5Rtxf{^MOX*4`?&a?htub;9>tm^9uivgiM@PBmxx-F;;ipx3FUss0idKg)Zbe}>qHZT;R zTMh@*%e_{K30NsR4n=D?U>T;ia+&-+3RtM6d#48RL%LC+clc>Mlvc7hguyIv9A`q4 ziZ&inR4k`+Sy2v6U^%I1p*2g&1VqDF52;#ke@{qHXp&0wP$G>JI9uZ4we(s)RsuYCrdXf z#14N|(`w6{%P!J7U~a7Sd8jHgsI;&5UVeGs$ztu`!oHJt_s_FmxaTJy*6o1R%<)3) zV5wvGw>?W8AKptXck~zefrXC#JKN^Aeg5P5#)t0Bu$t*B@B;?b!R3zLB0sj!(K|o+ zV8`D=h4XmfD83$>jn19B5u1&{fk8X=)Zv`n`?LJqnV*01)YDvO=`VT)3fw@+(=d1B z^K_vlP-yNbxH}7c*G%72m*?8vnPa8;hMAMkoVBytOD(OpTW+;{wdty7mMgg$7G0Yj z^Nl5rzrkJQ=9(8dU&-rR@@|`Tz|~oDt!M7a^$+J$cgDU}{uEtoJ@lR9@2>l<2gi#a zonO2tE`B7I_zjQv%?teI`LWyMx5n>oE4GFf`JE+S+t;DHJ0AIV6@0s9Pd=;lUeg!6 z(R=v^oy&(KMQ`+menbCk{nn$}=wfZO)V$@+-lgWioUK&Xbd&!TKacNT_(S|&WGQ&8 z(ABrJy|1vjf4Oepdk5OI`#FdB4F$I8^~(UNJH@=F`~dvNbmIYb-8<04{?R+&WAE43 z4z#iNx4WUITs)o?B{?3?dxAlz6TvYo5_qEM=Kz?H0)Q3jNr{eq|r9B2C~oE>%+!!K|3jl-+OKIax{oih^Z)yz#6u3rblv6JWtJ#*3@Olxs_pnSbjFYXl*GqZ7ldY3N4+*#x4L&{id6(zixf#zgP%gC_pdb!bJW#LPstAmhTbr z4p?UGj~klj8*lr*^xf(I-Ox8f#g^U0hCQ>~Ggrga1CROof_LA&#=o}wx#hb9#hz13 z-cyCMkw<4^3uj|X{G}3Kd*k?L$LG%ca`1%{{ed< BB6t7* literal 0 HcmV?d00001 From 2466fbd66f004be486d71082b87c8904ee611ad7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 00:34:21 +0300 Subject: [PATCH 07/10] chore: remove generated Python bytecode Co-authored-by: Medulla --- .../check-bus-boundaries.cpython-314.pyc | Bin 3349 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 scripts/__pycache__/check-bus-boundaries.cpython-314.pyc diff --git a/scripts/__pycache__/check-bus-boundaries.cpython-314.pyc b/scripts/__pycache__/check-bus-boundaries.cpython-314.pyc deleted file mode 100644 index 63263e6070b11eae81fe0a758524bd48cc17ff01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3349 zcmaJ@TWk~A89rl=?TI~h5+{MgEQ!NyF*6ZY&!FNrJ$2=+y)0XHW#vqRY2tY8^VyeC1_Y8mvbn5|k=25wub&joAs3V2zocVb&O4Adp&z$BzMSot0rF+5Ukg55k%GZEXRb-r1F zKsTe>M{Fkj2bGEH=te3Rv771H$0WBJoFn$I;qkYcd2139PWv)?vj@(TpaUc{+=@8D zHE&&jWu(T`tPMp2)xKC~pVOoQ`+fslhss(o!aDDbl#7NTqX#^b0{?=hOxXQHse;4g zHv(~AX=T5)m+x@ldUJPe6u5B1+RB_oRy8v@f*mKS?GS3JS5Rtxf{^MOX*4`?&a?htub;9>tm^9uivgiM@PBmxx-F;;ipx3FUss0idKg)Zbe}>qHZT;R zTMh@*%e_{K30NsR4n=D?U>T;ia+&-+3RtM6d#48RL%LC+clc>Mlvc7hguyIv9A`q4 ziZ&inR4k`+Sy2v6U^%I1p*2g&1VqDF52;#ke@{qHXp&0wP$G>JI9uZ4we(s)RsuYCrdXf z#14N|(`w6{%P!J7U~a7Sd8jHgsI;&5UVeGs$ztu`!oHJt_s_FmxaTJy*6o1R%<)3) zV5wvGw>?W8AKptXck~zefrXC#JKN^Aeg5P5#)t0Bu$t*B@B;?b!R3zLB0sj!(K|o+ zV8`D=h4XmfD83$>jn19B5u1&{fk8X=)Zv`n`?LJqnV*01)YDvO=`VT)3fw@+(=d1B z^K_vlP-yNbxH}7c*G%72m*?8vnPa8;hMAMkoVBytOD(OpTW+;{wdty7mMgg$7G0Yj z^Nl5rzrkJQ=9(8dU&-rR@@|`Tz|~oDt!M7a^$+J$cgDU}{uEtoJ@lR9@2>l<2gi#a zonO2tE`B7I_zjQv%?teI`LWyMx5n>oE4GFf`JE+S+t;DHJ0AIV6@0s9Pd=;lUeg!6 z(R=v^oy&(KMQ`+menbCk{nn$}=wfZO)V$@+-lgWioUK&Xbd&!TKacNT_(S|&WGQ&8 z(ABrJy|1vjf4Oepdk5OI`#FdB4F$I8^~(UNJH@=F`~dvNbmIYb-8<04{?R+&WAE43 z4z#iNx4WUITs)o?B{?3?dxAlz6TvYo5_qEM=Kz?H0)Q3jNr{eq|r9B2C~oE>%+!!K|3jl-+OKIax{oih^Z)yz#6u3rblv6JWtJ#*3@Olxs_pnSbjFYXl*GqZ7ldY3N4+*#x4L&{id6(zixf#zgP%gC_pdb!bJW#LPstAmhTbr z4p?UGj~klj8*lr*^xf(I-Ox8f#g^U0hCQ>~Ggrga1CROof_LA&#=o}wx#hb9#hz13 z-cyCMkw<4^3uj|X{G}3Kd*k?L$LG%ca`1%{{ed< BB6t7* From ca1baa31997a9a7aa79dfecca0c536d25e0de9ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 01:02:53 +0300 Subject: [PATCH 08/10] test(release): cover semver validation in release workflow Added shared SemVer cases and tests asserting that module packaging rejects malformed versions before invoking Cargo, that the PowerShell guard applies the same rules, and that tag resolution accepts or rejects tags accordingly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/release_workflow_tests.py | 52 ++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/scripts/release_workflow_tests.py b/scripts/release_workflow_tests.py index 72b826ae..9a3ee8ef 100644 --- a/scripts/release_workflow_tests.py +++ b/scripts/release_workflow_tests.py @@ -11,6 +11,26 @@ ROOT = Path(__file__).resolve().parent.parent +VERSION_CASES = [ + ("1.2.3", True), + ("0.0.0", True), + ("1.2.3-beta.1", True), + ("1.2.3-0.alpha-1+build.001", True), + ("1.2.3+001.build", True), + ("1.2.3-01a", True), + ("1.2.3-..", False), + ("1.2.3-01", False), + ("1.2.3-alpha..1", False), + ("1.2.3-alpha.01", False), + ("1.2.3+build..1", False), + ("01.2.3", False), + ("1.02.3", False), + ("1.2.03", False), + ("1.2.3-", False), + ("1.2.3+", False), + ("1.2.3\n", False), +] + class ReleaseWorkflowTests(unittest.TestCase): @classmethod @@ -179,18 +199,42 @@ def test_module_verification_canonicalizes_a_symlinked_temp_root(self): ) self.assertEqual(result.returncode, 0, result.stderr) - def test_existing_tag_resolves_without_a_version_bump_ancestor(self): + def test_module_build_validates_semver_before_building(self): + """Packaging rejects malformed versions before invoking Cargo.""" + for version, valid in VERSION_CASES: + with self.subTest(version=version): + result = subprocess.run( + ["bash", str(ROOT / "scripts/build-module.sh"), + "--crate", "tinywallet-module", "--version", version, + "--target", "unsupported-target", "--host", "test-host"], + capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("unsupported module target" if valid else "usage:", result.stderr) + + def test_powershell_version_pattern_validates_complete_semver(self): + """The Windows packaging guard uses the same SemVer rules.""" + script = (ROOT / "scripts/build-module.ps1").read_text() + pattern = re.search(r"\$Version -notmatch '([^']+)'", script).group(1) + # Python's fullmatch models the strict end anchor used by .NET. + pattern = pattern.removesuffix(r"\z") + for version, valid in VERSION_CASES: + with self.subTest(version=version): + self.assertEqual(re.fullmatch(pattern, version) is not None, valid) + + def test_resolve_step_validates_tag_format_and_outputs(self): """Tag resolution rejects invalid refs and passes valid version tags through.""" script = self.workflow.split(" run: |\n", 1)[1].split(" module-release:", 1)[0] script = "\n".join(line[10:] for line in script.splitlines()) with tempfile.TemporaryDirectory() as directory: - for tag, valid in [("v1.0.0", True), ("v1.0.0-beta.1", True), ("main", False), - ("v1.0.0; echo injected", False)]: + for tag, valid in [("v" + version, valid) for version, valid in VERSION_CASES] + [ + ("main", False), ("v1.0.0; echo injected", False) + ]: output = Path(directory) / "output" output.write_text("") result = subprocess.run(["bash", "-c", script], env=dict(os.environ, TAG=tag, GITHUB_OUTPUT=str(output)), capture_output=True, text=True) - self.assertEqual(result.returncode == 0, valid, result.stderr) + self.assertEqual(result.returncode == 0, valid, (tag, result.stderr)) if valid: self.assertEqual(output.read_text(), "tag="+tag+"\nversion="+tag[1:]+"\n") From 27289bdc2c8531eac53d1cfb019406f09167e4ca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 01:03:07 +0300 Subject: [PATCH 09/10] fix(ci): validate semver tags against the full spec The release workflow and module build scripts now use a stricter semver pattern that rejects leading zeros and accepts the complete prerelease and build metadata grammar. The previous pattern let malformed versions such as v01.2.3 through validation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/release.yml | 2 +- scripts/build-module.ps1 | 2 +- scripts/build-module.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64a47bd7..5bd5e266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} run: | set -euo pipefail - [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || { echo "tag must be v-prefixed semver" >&2 exit 2 } diff --git a/scripts/build-module.ps1 b/scripts/build-module.ps1 index 9cf948c4..af569b74 100644 --- a/scripts/build-module.ps1 +++ b/scripts/build-module.ps1 @@ -10,7 +10,7 @@ $ErrorActionPreference = 'Stop' $root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path Set-Location $root if ($Crate -ne 'tinywallet-module') { throw "Unsupported module crate: $Crate" } -if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$') { throw "Invalid module version: $Version" } +if ($Version -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?\z') { throw "Invalid module version: $Version" } if ($HostKey -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid release host key: $HostKey" } $pathSeparators = [char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) if ([IO.Path]::IsPathRooted($OutputDir) -or $OutputDir.Split($pathSeparators, [StringSplitOptions]::None) -contains '..') { throw 'Output directory must be a relative path inside the repository' } diff --git a/scripts/build-module.sh b/scripts/build-module.sh index 8ecd8c31..5b7f1966 100755 --- a/scripts/build-module.sh +++ b/scripts/build-module.sh @@ -20,7 +20,7 @@ while (($#)); do *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -[[ "$crate" == tinywallet-module && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ && -n "$rust_target" && "$host" =~ ^[A-Za-z0-9._-]+$ ]] || { +[[ "$crate" == tinywallet-module && "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ && -n "$rust_target" && "$host" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "usage: build-module.sh --crate tinywallet-module --version VERSION --target TARGET --host HOST [--extra-files-json JSON] [--output-dir DIR]" >&2 exit 2 } From 8fd9b113131c0a49b495f3f9ffd84d9739319016 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 12 Oct 2026 01:04:17 +0300 Subject: [PATCH 10/10] test(release): assert strict version anchoring and empty output on failure The Windows packaging guard test now checks that the SemVer pattern ends with the strict end anchor, since .NET would otherwise accept trailing newlines. The tag validation test also asserts that invalid tags produce no output file content. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/release_workflow_tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/release_workflow_tests.py b/scripts/release_workflow_tests.py index 9a3ee8ef..a7e7d65a 100644 --- a/scripts/release_workflow_tests.py +++ b/scripts/release_workflow_tests.py @@ -216,6 +216,7 @@ def test_powershell_version_pattern_validates_complete_semver(self): """The Windows packaging guard uses the same SemVer rules.""" script = (ROOT / "scripts/build-module.ps1").read_text() pattern = re.search(r"\$Version -notmatch '([^']+)'", script).group(1) + self.assertTrue(pattern.endswith(r"\z"), ".NET must reject trailing newlines") # Python's fullmatch models the strict end anchor used by .NET. pattern = pattern.removesuffix(r"\z") for version, valid in VERSION_CASES: @@ -237,6 +238,8 @@ def test_resolve_step_validates_tag_format_and_outputs(self): self.assertEqual(result.returncode == 0, valid, (tag, result.stderr)) if valid: self.assertEqual(output.read_text(), "tag="+tag+"\nversion="+tag[1:]+"\n") + else: + self.assertEqual(output.read_text(), "") if __name__ == "__main__": unittest.main()