diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00f341a..f676b2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,11 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest environment: Production + # Consumed by `native-bundles` and `github-release`: both check out the tag + # this job created, and the archive names embed the version it computed. + outputs: + next_version: ${{ steps.version.outputs.next_version }} + tag: ${{ steps.version.outputs.tag }} env: CRATE_NAME: tinychannels steps: @@ -198,3 +203,238 @@ jobs: exit 1 env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # The crate publish above is not the deliverable for `tinychannels-module`: + # it is `publish = false`, and what a host loads is the compiled `cdylib`. + # OpenHuman's `modules::registry` pins each artifact by a SHA-256 digest taken + # verbatim from a release, so without these jobs the module cannot be pinned + # and therefore cannot be loaded at all. + # + # The matrix is per-distro on purpose, not per-target. A `.so` built against + # glibc 2.39 fails to `dlopen` on a 2.35 host with a symbol-version error, so + # the host probes glibc and picks the newest build that could work — which + # only helps if the older build exists. + native-bundles: + name: Module bundle (${{ matrix.id }}) + needs: publish-rust + strategy: + # One unavailable runner should not cost the other ten artifacts; the + # asset-count check in `github-release` is what refuses a partial set. + fail-fast: false + matrix: + include: + - id: ubuntu-22.04-x86_64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - id: ubuntu-22.04-arm64 + os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + - id: ubuntu-24.04-x86_64 + os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - id: ubuntu-24.04-arm64 + os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - id: macos-15-x86_64 + os: macos-15-intel + target: x86_64-apple-darwin + - id: macos-15-arm64 + os: macos-15 + target: aarch64-apple-darwin + - id: macos-26-x86_64 + os: macos-26-intel + target: x86_64-apple-darwin + - id: macos-26-arm64 + os: macos-26 + target: aarch64-apple-darwin + - id: windows-2022-x86_64 + os: windows-2022 + target: x86_64-pc-windows-msvc + - id: windows-2025-x86_64 + os: windows-2025 + target: x86_64-pc-windows-msvc + - id: windows-11-arm64 + os: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + # The tag the publish job just created, so the artifact matches the + # released source rather than whatever `main` has moved on to. + ref: ${{ needs.publish-rust.outputs.tag }} + persist-credentials: false + submodules: true + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + # The whole point of the per-distro matrix is that each `id` describes the + # machine the artifact was built on. A runner image that silently changed + # architecture would produce a correctly-named artifact that cannot load. + - name: Verify native Rust target + shell: bash + env: + EXPECTED_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + actual_target="$(rustc -vV | sed -n 's/^host: //p')" + [[ "$actual_target" == "$EXPECTED_TARGET" ]] + + - name: Build installable module + run: cargo build --locked --release --package tinychannels-module + + - name: Assemble Unix module package + if: ${{ runner.os != 'Windows' }} + id: unix_package + shell: bash + env: + BUNDLE_ID: ${{ matrix.id }} + VERSION: ${{ needs.publish-rust.outputs.next_version }} + run: | + set -euo pipefail + + library_name="tinychannels_module" + case "$RUNNER_OS" in + Linux) module="target/release/lib${library_name}.so" ;; + macOS) module="target/release/lib${library_name}.dylib" ;; + *) echo "unsupported Unix runner: ${RUNNER_OS}" >&2; exit 1 ;; + esac + package_name="tinychannels-module-${VERSION}-${BUNDLE_ID}" + package_root="dist/${package_name}" + mkdir -p "$package_root" + install -m 755 "$module" "$package_root/" + install -m 644 LICENSE README.md "$package_root/" + # The in-archive digest is the module's own; the release-level + # `checksum.toml` covers the archives. The host checks both. + module_name="$(basename "$module")" + # macOS runners don't ship GNU coreutils' `sha256sum`; only `shasum` + # is guaranteed. Prefer `sha256sum` where it exists (Linux) and fall + # back to `shasum -a 256` (macOS) rather than assuming either. + if command -v sha256sum >/dev/null 2>&1; then + module_hash="$(sha256sum "$package_root/$module_name" | awk '{print $1}')" + else + module_hash="$(shasum -a 256 "$package_root/$module_name" | awk '{print $1}')" + fi + printf '"%s" = "%s"\n' "$module_name" "$module_hash" \ + > "$package_root/modules.toml" + tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . + echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Assemble Windows module package + if: ${{ runner.os == 'Windows' }} + id: windows_package + shell: pwsh + env: + BUNDLE_ID: ${{ matrix.id }} + VERSION: ${{ needs.publish-rust.outputs.next_version }} + run: | + $ErrorActionPreference = 'Stop' + $libraryName = 'tinychannels_module' + $module = "target/release/$libraryName.dll" + $packageName = "tinychannels-module-$env:VERSION-$env:BUNDLE_ID" + $packageRoot = "dist/$packageName" + New-Item -ItemType Directory -Force $packageRoot | Out-Null + Copy-Item -LiteralPath $module, 'LICENSE', 'README.md' -Destination $packageRoot + $hash = (Get-FileHash -LiteralPath $module -Algorithm SHA256).Hash.ToLowerInvariant() + $moduleName = Split-Path -Leaf $module + "`"$moduleName`" = `"$hash`"`n" | + Set-Content -Path "$packageRoot/modules.toml" -Encoding utf8NoBOM + Compress-Archive -Path "$packageRoot/*" -DestinationPath "dist/$packageName.zip" + "archive=dist/$packageName.zip" >> $env:GITHUB_OUTPUT + + - name: Upload Unix module package + if: ${{ runner.os != 'Windows' }} + uses: actions/upload-artifact@v7 + with: + name: tinychannels-module-${{ matrix.id }} + path: ${{ steps.unix_package.outputs.archive }} + if-no-files-found: error + + - name: Upload Windows module package + if: ${{ runner.os == 'Windows' }} + uses: actions/upload-artifact@v7 + with: + name: tinychannels-module-${{ matrix.id }} + path: ${{ steps.windows_package.outputs.archive }} + if-no-files-found: error + + github-release: + name: Create GitHub release + needs: + - publish-rust + - native-bundles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.publish-rust.outputs.tag }} + persist-credentials: false + submodules: true + + - name: Download workflow artifacts + uses: actions/download-artifact@v8 + with: + pattern: tinychannels-module-* + path: release-assets + merge-multiple: true + + - uses: dtolnay/rust-toolchain@stable + + # TinyBus writes the manifest the host compares against, so the format + # cannot drift from what the loader parses. `fail-fast: false` above means + # a missing runner yields a partial set — refuse it rather than publish a + # release some platforms silently cannot install from. + - name: Create release checksum manifest with TinyBus + shell: bash + run: | + set -euo pipefail + mapfile -t assets < <( + find release-assets -type f \ + \( -name '*.tar.gz' -o -name '*.zip' \) \ + | sort + ) + if [[ ${#assets[@]} -ne 11 ]]; then + printf 'expected 11 module archives, found %s:\n' "${#assets[@]}" >&2 + find release-assets -type f -print >&2 || true + exit 1 + fi + checksum_args=() + for asset in "${assets[@]}"; do checksum_args+=(--path "$asset"); done + cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ + --package tinybus --all-features --bin tinybus -- \ + modules checksum "${checksum_args[@]}" --output release-assets/checksum.toml + + - name: Create release and upload assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.publish-rust.outputs.tag }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mapfile -t release_files < <(find release-assets -type f | sort) + gh release create "$RELEASE_TAG" "${release_files[@]}" \ + --repo "$REPOSITORY" \ + --verify-tag \ + --title "$RELEASE_TAG" \ + --generate-notes + + # Downloads what was just published and loads it the way a host does, so a + # release that cannot actually be installed fails here rather than in the + # field on whichever platform nobody tested. + - name: Verify the published module through TinyBus + shell: bash + env: + RELEASE_TAG: ${{ needs.publish-rust.outputs.tag }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.publish-rust.outputs.next_version }} + run: | + set -euo pipefail + archive="tinychannels-module-${VERSION}-ubuntu-24.04-x86_64.tar.gz" + release_url="https://github.com/${REPOSITORY}/releases/tag/${RELEASE_TAG}" + sha256="$(sed -n "s/^\"${archive}\" = \"\([0-9a-f]\{64\}\)\"$/\1/p" release-assets/checksum.toml)" + test -n "$sha256" + cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ + --package tinybus --all-features --example github_module_host -- \ + "$release_url" "$archive" "$sha256" diff --git a/.gitignore b/.gitignore index 9ae7973..18ac566 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ target/ **/*.rs.bk +# Release staging built by the `native-bundles` job. Local runs of that +# packaging step leave a multi-megabyte `cdylib` and its archive here, and an +# auto-commit hook will happily commit them. +dist/ + # Local secrets — never commit; copy .env.example to .env .env .DS_Store