diff --git a/.github/workflows/published-install.yml b/.github/workflows/published-install.yml index adf8011..a896f52 100644 --- a/.github/workflows/published-install.yml +++ b/.github/workflows/published-install.yml @@ -17,7 +17,7 @@ permissions: jobs: package: name: Resolve package - if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + if: github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release') runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -47,7 +47,7 @@ jobs: fi for attempt in 1 2 3 4 5 6; do - if npm view "$spec" version; then + if version="$(npm view "$spec" version)"; then break fi if [ "$attempt" = "6" ]; then @@ -56,6 +56,7 @@ jobs: sleep $((attempt * 10)) done + spec="@webrtc-node/webrtc@$version" echo "spec=$spec" >> "$GITHUB_OUTPUT" echo "Testing $spec" >> "$GITHUB_STEP_SUMMARY" @@ -79,11 +80,15 @@ jobs: image: node:24-bookworm steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - name: Install published package env: PACKAGE_SPEC: ${{ needs.package.outputs.spec }} run: | docker run --rm \ + -v "$PWD/examples/datachannel.js:/smoke-source.js:ro" \ -e PACKAGE_SPEC \ -e WEBRTC_NODE_PREBUILD_ONLY=1 \ "${{ matrix.image }}" \ @@ -102,6 +107,8 @@ jobs: done node -e "const { RTCPeerConnection } = require(\"@webrtc-node/webrtc\"); const pc = new RTCPeerConnection(); pc.close(); console.log(\"cjs ok\");" node --input-type=module -e "import { RTCPeerConnection } from \"@webrtc-node/webrtc\"; const pc = new RTCPeerConnection(); pc.close(); console.log(\"esm ok\");" + sed "s|require(\"..\")|require(\"@webrtc-node/webrtc\")|" /smoke-source.js > smoke.cjs + node smoke.cjs ' hosted: @@ -123,6 +130,9 @@ jobs: os: windows-11-arm steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - uses: actions/setup-node@v6 with: node-version: 24 @@ -149,3 +159,5 @@ jobs: done node -e "const { RTCPeerConnection } = require('@webrtc-node/webrtc'); const pc = new RTCPeerConnection(); pc.close(); console.log('cjs ok');" node --input-type=module -e "import { RTCPeerConnection } from '@webrtc-node/webrtc'; const pc = new RTCPeerConnection(); pc.close(); console.log('esm ok');" + sed 's|require("..")|require("@webrtc-node/webrtc")|' "$GITHUB_WORKSPACE/examples/datachannel.js" > smoke.cjs + node smoke.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 093793b..f8d2b61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,16 +2,6 @@ name: Release on: workflow_dispatch: - inputs: - npm_tag: - description: npm distribution tag (used only when publish_npm is enabled) - required: true - default: next - publish_npm: - description: Publish to npm after uploading prebuild assets (explicit opt-in) - required: true - type: boolean - default: false release: types: [published] @@ -52,11 +42,11 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} - name: Build Linux prebuild run: | - docker build \ + docker build --no-cache --pull \ -f "${{ matrix.dockerfile }}" \ ${{ matrix.build_args }} \ -t "${{ matrix.image }}" . @@ -64,7 +54,24 @@ jobs: -v "$PWD:/usr/app" \ -w /usr/app \ "${{ matrix.image }}" \ - sh -lc "npm ci --ignore-scripts && npm run build -- --CDWEBRTC_NODE_STATIC_OPENSSL=ON && npm run prebuild:package -- --platform=linux --arch=${{ matrix.arch }} --libc=${{ matrix.libc }} && node -e \"const { RTCPeerConnection } = require('./'); const pc = new RTCPeerConnection(); pc.close();\"" + sh -lc "npm ci --ignore-scripts && npm run build -- --CDWEBRTC_NODE_STATIC_OPENSSL=ON && npm run native:check && npm test && npm run api:check && npm run types:check && npm run prebuild:package -- --platform=linux --arch=${{ matrix.arch }} --libc=${{ matrix.libc }} && node -e \"const { RTCPeerConnection } = require('./'); const pc = new RTCPeerConnection(); pc.close();\" && npm exec -c \"node scripts/validate-prebuild-install.js\"" + + - name: Inspect Linux addon linkage + run: | + docker run --rm -v "$PWD:/usr/app" -w /usr/app "${{ matrix.image }}" sh -lc 'set -eu + cmake --version + readelf -h build/Release/webrtc_node.node + readelf -d build/Release/webrtc_node.node > /tmp/addon-dependencies + cat /tmp/addon-dependencies + readelf --version-info build/Release/webrtc_node.node + ! grep -E "Shared library:.*(libssl|libcrypto)" /tmp/addon-dependencies + test "$(grep -Ec "OPENSSL_(CRYPTO|SSL)_LIBRARY:FILEPATH=.*\.a$" build/CMakeCache.txt)" = 2 + if [ "${{ matrix.libc }}" = glibc ]; then + test "$(getconf GNU_LIBC_VERSION)" = "glibc 2.31" + newest="$(readelf --version-info build/Release/webrtc_node.node | grep -oE "GLIBC_[0-9.]+" | sed "s/GLIBC_//" | sort -Vu | tail -1)" + test "$(printf "%s\n" "$newest" 2.31 | sort -V | tail -1)" = 2.31 + fi + ' - uses: actions/upload-artifact@v7 with: @@ -89,7 +96,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} - uses: actions/setup-node@v6 with: @@ -102,10 +109,27 @@ jobs: - run: npm run build -- --arch=${{ matrix.arch }} --CDWEBRTC_NODE_STATIC_OPENSSL=ON + - name: Verify static OpenSSL selection + shell: bash + run: | + test "$(grep -Ec 'OPENSSL_(CRYPTO|SSL)_LIBRARY:FILEPATH=.*\.(a|lib)$' build/CMakeCache.txt)" = 2 + + - name: Inspect macOS addon linkage + run: | + otool -L build/Release/webrtc_node.node | tee /tmp/addon-dependencies + ! grep -Ei '/(libssl|libcrypto)[^/]*\.dylib' /tmp/addon-dependencies + + - run: npm run native:check + - run: npm test + - run: npm run api:check + - run: npm run types:check + - run: npm run prebuild:package -- --platform=darwin --arch=${{ matrix.arch }} - run: node -e "const { RTCPeerConnection } = require('./'); const pc = new RTCPeerConnection(); pc.close();" + - run: npm exec -c "node scripts/validate-prebuild-install.js" + - uses: actions/upload-artifact@v7 with: name: prebuild-${{ matrix.target }} @@ -129,7 +153,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} - uses: actions/setup-node@v6 with: @@ -142,32 +166,71 @@ jobs: - run: npm run build -- --arch=${{ matrix.arch }} --CDWEBRTC_NODE_STATIC_OPENSSL=ON + - name: Verify static OpenSSL selection + shell: pwsh + run: | + $cache = Get-Content build/CMakeCache.txt + foreach ($library in @('LIB_EAY_RELEASE', 'SSL_EAY_RELEASE')) { + if (-not ($cache -match "^${library}:FILEPATH=.*vcpkg_installed/${{ matrix.arch }}-windows-static/lib/lib(crypto|ssl)\.lib$")) { + throw "Expected a static vcpkg release library for $library" + } + } + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' + $vsRoot = & $vswhere -latest -products '*' -property installationPath + $dumpbin = Get-ChildItem (Join-Path $vsRoot 'VC/Tools/MSVC') -Filter dumpbin.exe -Recurse | + Where-Object { $_.FullName -match 'bin\\Host(x64|arm64)\\${{ matrix.arch }}\\dumpbin.exe$' } | + Select-Object -First 1 + if (-not $dumpbin) { throw 'dumpbin was not found' } + $dependencies = & $dumpbin.FullName /DEPENDENTS build/Release/webrtc_node.node + if ($LASTEXITCODE -ne 0) { throw 'dumpbin failed' } + $dependencies + if ($dependencies -match '(?i)(libcrypto|libssl|ssleay|libeay).*\.dll') { + throw 'The release addon must not depend on an OpenSSL DLL' + } + + - run: npm run native:check + - run: npm test + - run: npm run api:check + - run: npm run types:check + - run: npm run prebuild:package -- --platform=win32 --arch=${{ matrix.arch }} - run: node -e "const { RTCPeerConnection } = require('./'); const pc = new RTCPeerConnection(); pc.close();" + - run: npm exec -c "node scripts/validate-prebuild-install.js" + - uses: actions/upload-artifact@v7 with: name: prebuild-${{ matrix.target }} path: prebuild-artifacts/* - publish: - name: Publish npm package + source-fallback: + name: Linux ARM64 musl source fallback + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} + - name: Verify unsupported prebuild falls back to source + run: | + docker build --no-cache --pull -f build-containers/Dockerfile.alpine --build-arg NODE_VERSION=20 --build-arg ALPINE_VERSION=3.20 -t webrtc-node-fallback . + docker run --rm -v "$PWD:/usr/app" -w /usr/app webrtc-node-fallback sh -lc 'npm ci --ignore-scripts && npm exec -c "node scripts/validate-prebuild-install.js --source-fallback"' + + validate: + name: Validate prebuild assets runs-on: ubuntu-latest - permissions: - contents: write - id-token: write needs: - prebuild-linux - prebuild-macos - prebuild-windows + - source-fallback timeout-minutes: 20 - environment: npm steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }} - uses: actions/setup-node@v6 with: @@ -199,33 +262,64 @@ jobs: exit 1 fi - - name: Upload prebuild release assets + publish: + name: Publish npm package + if: github.event_name == 'release' + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: npm + permissions: + contents: write + id-token: write + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + - uses: actions/setup-node@v6 + with: + node-version: 24 + package-manager-cache: false + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@v8 + with: + pattern: prebuild-* + path: prebuild-artifacts + merge-multiple: true + - run: npm ci --ignore-scripts + - run: npm run prebuild:check + + - name: Refuse an existing npm version or release asset env: GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} RELEASE_TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail - tag="$RELEASE_TAG" - if [ "$EVENT_NAME" != "release" ]; then - tag="v$(node -p "require('./package.json').version")" + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + const { name, version } = JSON.parse(readFileSync('package.json', 'utf8')); + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`); + if (response.status !== 404) throw new Error(`Publication requires an unused npm version; registry returned ${response.status}`); + NODE + if [ "$(gh release view "$RELEASE_TAG" --json assets --jq '.assets | length')" != "0" ]; then + echo "Release already has assets; inspect publication state before retrying." + exit 1 fi - gh release view "$tag" >/dev/null - gh release upload "$tag" prebuild-artifacts/* --clobber + + - name: Upload prebuild release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" prebuild-artifacts/* - name: Publish to npm - if: github.event_name != 'workflow_dispatch' || inputs.publish_npm env: - EVENT_NAME: ${{ github.event_name }} - INPUT_NPM_TAG: ${{ inputs.npm_tag }} RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} run: | set -euo pipefail - npm_tag="$INPUT_NPM_TAG" - if [ "$EVENT_NAME" != "workflow_dispatch" ]; then - npm_tag="latest" - if [ "$RELEASE_PRERELEASE" = "true" ]; then - npm_tag="next" - fi + npm_tag="latest" + if [ "$RELEASE_PRERELEASE" = "true" ]; then + npm_tag="next" fi npm publish --access public --tag "$npm_tag" --provenance diff --git a/package-lock.json b/package-lock.json index efebd32..a5fb915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@webrtc-node/webrtc", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@webrtc-node/webrtc", - "version": "0.2.1", + "version": "0.2.2", "hasInstallScript": true, "license": "MPL-2.0", "dependencies": { diff --git a/package.json b/package.json index 0f2456c..dc50aaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webrtc-node/webrtc", - "version": "0.2.1", + "version": "0.2.2", "description": "Data-channel-first WebRTC for Node.js with W3C-style RTCPeerConnection and RTCDataChannel APIs.", "author": "webrtc-node contributors", "homepage": "https://github.com/webrtc-node/webrtc#readme", diff --git a/scripts/validate-prebuild-install.js b/scripts/validate-prebuild-install.js new file mode 100644 index 0000000..ce4963e --- /dev/null +++ b/scripts/validate-prebuild-install.js @@ -0,0 +1,123 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const libc = require("detect-libc"); +const { validateArchiveFile } = require("./prebuild-integrity"); + +const root = path.resolve(__dirname, ".."); +const packageJson = require("../package.json"); +const sourceFallback = process.argv.includes("--source-fallback"); +const libcTag = process.platform === "linux" ? libc.familySync() : null; +const tuple = [process.platform, process.arch, libcTag].filter(Boolean).join("-"); +const archiveName = `webrtc-node-v${packageJson.version}-napi-v8-${tuple}.tar.gz`; +const artifacts = path.join(root, "prebuild-artifacts"); +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "webrtc-install-validation-")); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: scratch, + stdio: "inherit", + timeout: 600000, + ...options, + }); + if (result.error) throw result.error; + assert.equal(result.signal, null, `unexpected signal from ${command}`); + assert.equal(result.status, 0, `${command} failed`); +} + +function npm(args, options) { + assert.ok(process.env.npm_execpath, "invoke this check through npm exec"); + run(process.execPath, [process.env.npm_execpath, ...args], options); +} + +async function main() { + if (sourceFallback) { + assert.equal(tuple, "linux-arm64-musl"); + } else { + const archivePath = path.join(artifacts, archiveName); + const options = { + archivePath, + archiveName, + checksumContents: fs.readFileSync(`${archivePath}.sha256`, "utf8"), + platform: process.platform, + arch: process.arch, + libcTag, + }; + const addon = await validateArchiveFile(options); + fs.rmSync(path.dirname(addon), { recursive: true, force: true }); + await assert.rejects( + validateArchiveFile({ ...options, arch: process.arch === "arm64" ? "x64" : "arm64" }), + /target mismatch|unsupported Linux libc target/, + ); + if (libcTag) { + await assert.rejects( + validateArchiveFile({ ...options, libcTag: libcTag === "glibc" ? "musl" : "glibc" }), + /target mismatch|unsupported Linux libc target/, + ); + } + } + + npm(["pack", "--pack-destination", scratch], { cwd: root }); + const tarball = fs.readdirSync(scratch).find((file) => file.endsWith(".tgz")); + assert.ok(tarball); + fs.writeFileSync(path.join(scratch, "package.json"), '{"private":true}\n'); + npm(["install", path.join(scratch, tarball), "--ignore-scripts"]); + const installedRoot = path.join(scratch, "node_modules", "@webrtc-node", "webrtc"); + const installedAddon = path.join(installedRoot, "build", "Release", "webrtc_node.node"); + assert.equal(fs.existsSync(installedAddon), false); + + // Serve the unpublished archive locally while exercising the real downloader, + // checksum validation, extraction, target checks, and install lifecycle script. + const preload = path.join(scratch, "prebuild-fetch.cjs"); + fs.writeFileSync( + preload, + `const fs = require('node:fs'); +const expected = ${JSON.stringify(archiveName)}; +globalThis.fetch = async (url) => { + const parsed = new URL(url); + if (parsed.origin !== 'https://github.com' || !parsed.pathname.startsWith('/webrtc-node/webrtc/releases/download/v${packageJson.version}/')) throw new Error('unexpected download URL ' + url); + const name = parsed.pathname.split('/').pop(); + if (name !== expected && name !== expected + '.sha256') throw new Error('unexpected asset ' + name); + console.log('Installer requested ' + name); + ${sourceFallback ? "return new Response('', {status: 404, statusText: 'Not Found'});" : `return new Response(fs.readFileSync(require('node:path').join(${JSON.stringify(artifacts)}, name)));`} +}; +`, + ); + run(process.execPath, ["--require", preload, "scripts/install-native.js"], { + cwd: installedRoot, + env: { + ...process.env, + WEBRTC_NODE_PREBUILD_ONLY: sourceFallback ? "0" : "1", + npm_config_build_from_source: "false", + WEBRTC_NODE_NATIVE_PATH: "", + }, + }); + assert.ok(fs.existsSync(installedAddon)); + assert.equal(require(path.join(installedRoot, "package.json")).version, packageJson.version); + run(process.execPath, [ + "-e", + "const { RTCPeerConnection } = require('@webrtc-node/webrtc'); const pc = new RTCPeerConnection(); pc.close(); console.log('cjs ok');", + ]); + run(process.execPath, [ + "--input-type=module", + "-e", + "import { RTCPeerConnection } from '@webrtc-node/webrtc'; const pc = new RTCPeerConnection(); pc.close(); console.log('esm ok');", + ]); + const smoke = fs + .readFileSync(path.join(root, "examples", "datachannel.js"), "utf8") + .replace('require("..")', 'require("@webrtc-node/webrtc")'); + fs.writeFileSync(path.join(scratch, "smoke.cjs"), smoke); + run(process.execPath, ["smoke.cjs"], { timeout: 30000 }); + console.log( + `${sourceFallback ? "Source fallback" : "Prebuild-only installation"} verified for ${tuple}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});