From 732d6dcb710e641b3aa2c65f5cca9f122bdf7ba7 Mon Sep 17 00:00:00 2001 From: BL Date: Sun, 13 Sep 2026 05:31:02 +0000 Subject: [PATCH 1/2] build: add verified npm release and trusted publishing workflow --- .../003-npm-release-trusted-publishing.md | 54 +++ .github/RELEASING.md | 86 ++++ .github/workflows/npm-publish.yml | 420 ++++++++++++++++++ README.md | 8 +- scripts/pack-check.mjs | 131 +++++- 5 files changed, 691 insertions(+), 8 deletions(-) create mode 100644 .github/ISSUE_SPECS/003-npm-release-trusted-publishing.md create mode 100644 .github/RELEASING.md create mode 100644 .github/workflows/npm-publish.yml diff --git a/.github/ISSUE_SPECS/003-npm-release-trusted-publishing.md b/.github/ISSUE_SPECS/003-npm-release-trusted-publishing.md new file mode 100644 index 0000000..aca9831 --- /dev/null +++ b/.github/ISSUE_SPECS/003-npm-release-trusted-publishing.md @@ -0,0 +1,54 @@ +# Publish @misofm/codec 0.1.0 and configure trusted publishing + +GitHub: https://github.com/misofm/codec/issues/3 + +## Authorization and scope + +The user explicitly requested publication and npm trusted publishing on +2026-09-13. This authorizes the release actions that were excluded from issue 1. +The reviewed codec implementation is merged through PR 2 at main `544aaa9`. +Publish the real `@misofm/codec@0.1.0` package and configure a reusable GitHub +Actions OIDC publisher for `misofm/codec`. Preserve all codec source, Wasm assets, +peer/runtime support, and verification gates. No adapter/CLI/transcoder changes. + +## Release contract + +- Work from a reviewed, green, exact main commit. Keep the public package and + repository identity fixed; version 0.1.0 must be absent before publication. +- Build and test the release tarball using the existing fresh-consumer matrix. + Publish exactly the verified tarball, with no media, caches, dependencies, + credentials, or test fixtures added to the package. +- Because npm requires an existing package to configure trust, use the local + authorized npm CLI for the first real release. Record that this local bootstrap + has no CI provenance; keep provenance enabled for subsequent OIDC releases. + Do not publish placeholder versions or issue extra versions merely to test OIDC. +- Configure trusted publisher `misofm/codec`, workflow `npm-publish.yml`, with + direct `npm publish` permission. Do not place an npm write token in GitHub. + Use npm 11.19.1, which supports the current trust CLI, without changing the + user's global npm installation. +- Add an explicit GitHub-hosted release workflow with publish and verify modes, + exact main commit/version admission, concurrency control, pinned tooling, + and `contents: read` / `id-token: write` permissions. Never publish on a PR. + Run codec checks, native oracle verification, and packed consumers before a + publish. Future CI publication emits npm provenance automatically. +- Verify public registry identity, integrity, installed Wasm assets, and real + encode/decode through an isolated registry consumer. Verify the saved trusted + publisher configuration and exercise the workflow's read-only verify mode. +- A browser/passkey/OTP challenge is a registry authentication requirement, not + an additional permission request. Preserve the pending CLI command and ask + for only the authentication action npm requires if it cannot complete locally. + +## Evidence and completion gates + +Sol scopes and implements release tooling; fresh Sol reviews the immutable +release change. Record exact commit/tag, CI run, tested tarball SHA-256/integrity, +registry version and dist-tag, trust configuration, and registry-consumer result. +The task is complete when 0.1.0 is publicly installable, trusted publishing is +configured, and release verification is green. Do not claim a verify-only run +has exercised an OIDC publish. Any pending authentication remains explicit. + +## Current status + +The local npm account is authenticated with write access to the organization, +2FA is enabled, and `@misofm/codec` does not yet exist in the registry. Release +workflow implementation, review, publication, and trust configuration are pending. diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000..3c3f89d --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,86 @@ +# Releasing @misofm/codec + +Releases use a reviewed, green main commit and an exact version. The package is +built with Bun 1.4.2, verified on Node 22.23.2 and browser workers, and published +with npm 11.19.1. Keep the public package name and repository URL unchanged. + +## Preparing a release + +1. Update `package.json` and `bun.lock` for a new version, review the change, and + merge only after the ordinary, native, Wasm rebuild, and packed-consumer gates + pass. Never reuse a published version. +2. Record the full lowercase main commit SHA and exact package version. +3. Run the **npm-publish.yml** workflow on `main` with `mode=publish`, + `expected_sha=<40-character commit>`, and `expected_version=`. +4. Verify the registry result and create the matching GitHub release/tag at the + reviewed commit. Record the run URL and registry integrity in the issue spec. + +The workflow has three jobs. Qualification builds and verifies the package +without an OIDC permission, preserving the tarball only after every consumer +passes. The publishing job receives that artifact, verifies its digest and +identity, and publishes it without running repository code or lifecycle scripts. +It alone receives `id-token: write`; it has no npm write-token fallback. The +registry job downloads the published package and repeats the consumer matrix +without OIDC permission. + +`mode=verify` performs the same qualification and registry comparison without +publishing. Use it for an existing version with the same package contents. It +proves registry bytes and runtime behavior; it does not prove an OIDC publication. + +## Trusted publisher configuration + +The npm trusted publisher must match: + +| Field | Value | +| ----------------- | -------------------- | +| Provider | GitHub Actions | +| Repository | `misofm/codec` | +| Workflow filename | `npm-publish.yml` | +| Permission | Direct `npm publish` | + +Configure it with the maintained CLI, using the locally authenticated npm +account and completing npm's own browser/passkey challenge when requested: + +```sh +npm exec --yes --package npm@11.19.1 -- npm trust github @misofm/codec \ + --repo=misofm/codec --file=npm-publish.yml --allow-publish --yes +npm exec --yes --package npm@11.19.1 -- npm trust list @misofm/codec --json +``` + +This does not install or replace the local global npm client. GitHub-hosted +publishing uses short-lived OIDC credentials, and npm generates provenance for +those public releases. See the official [trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/) +and [`npm trust` prerequisites](https://docs.npmjs.com/cli/v11/commands/npm-trust/). + +## Initial 0.1.0 bootstrap + +npm requires the package to exist before trust can be configured. The first real +0.1.0 release therefore uses the authorized local npm CLI and the exact tested +tarball. Disable provenance only with an explicit flag on this one command: + +```sh +npm exec --yes --package npm@11.19.1 -- npm publish /absolute/path/to/tested.tgz \ + --access=public --tag=latest --ignore-scripts --provenance=false +``` + +Keep `publishConfig.provenance=true` in the package. The local bootstrap has no +GitHub/npm build attestation; the source revision, tests, pinned Wasm manifests, +and registry artifact equality are recorded independently. Configure trust after +0.1.0 exists, then exercise `mode=verify`. Do not invent a placeholder or extra +patch version just to test OIDC. + +## Local artifact verification + +The existing consumer script accepts these optional environment variables: + +| Variable | Purpose | +| ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| `CODEC_PACK_DESTINATION` | Pre-existing empty directory outside the repository; receives the verified tarball after all tests pass | +| `CODEC_PACK_RECEIPT` | JSON receipt path, including filename, SHA-256, and SHA-512 integrity | +| `CODEC_PACK_SOURCE` | Exact npm package spec to verify; omitted when packing the working tree | +| `CODEC_PACK_EXPECTED_SHA256` | Expected lowercase SHA-256 of the tarball | + +A registry check uses `CODEC_PACK_SOURCE=@misofm/codec@` and the expected +hash from the release qualification. It checks the actual registry tarball in +isolated installations across Effect rc.112/rc.115, Bun, Node, Chromium, Firefox, +and WebKit. Keep receipts in the issue evidence; do not commit the tarball. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..b2a2ecd --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,420 @@ +name: npm publish + +on: + workflow_dispatch: + inputs: + mode: + description: Compare an existing release or publish an absent version + required: true + type: choice + options: + - verify + - publish + expected_sha: + description: Exact lowercase SHA of the current main commit + required: true + type: string + expected_version: + description: Exact package version to qualify + required: true + type: string + +permissions: + contents: read + +concurrency: + group: npm-publish-${{ github.repository }} + cancel-in-progress: false + +env: + EXPECTED_PACKAGE: "@misofm/codec" + EXPECTED_REPOSITORY: "git+https://github.com/misofm/codec.git" + PINNED_NPM_VERSION: "11.19.1" + +jobs: + qualify: + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + tarball_filename: ${{ steps.artifact.outputs.filename }} + tarball_integrity: ${{ steps.artifact.outputs.integrity }} + tarball_sha256: ${{ steps.artifact.outputs.sha256 }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.expected_sha }} + persist-credentials: false + - name: Admit the exact main package + env: + DISPATCH_SHA: ${{ inputs.expected_sha }} + DISPATCH_VERSION: ${{ inputs.expected_version }} + run: | + set -euo pipefail + [[ "$DISPATCH_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_SHA" = "$DISPATCH_SHA" + test "$(git rev-parse HEAD)" = "$DISPATCH_SHA" + main_sha=$(git ls-remote --exit-code origin refs/heads/main | cut -f1) + test "$main_sha" = "$DISPATCH_SHA" + node --input-type=module <<'NODE' + import { readFile } from "node:fs/promises"; + const manifest = JSON.parse(await readFile("package.json", "utf8")); + const expected = { + name: process.env.EXPECTED_PACKAGE, + version: process.env.DISPATCH_VERSION, + repository: process.env.EXPECTED_REPOSITORY, + }; + if ( + manifest.name !== expected.name || + manifest.version !== expected.version || + manifest.repository?.type !== "git" || + manifest.repository?.url !== expected.repository + ) { + throw new Error( + `release identity mismatch: ${JSON.stringify({ + name: manifest.name, + version: manifest.version, + repository: manifest.repository, + })}`, + ); + } + NODE + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.23.2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - name: Select the pinned npm without a global install + run: | + set -euo pipefail + npm_bin=$(npm exec --yes --package="npm@$PINNED_NPM_VERSION" -- which npm) + test "$("$npm_bin" --version)" = "$PINNED_NPM_VERSION" + echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" + - run: bun install --frozen-lockfile + - run: bunx playwright install --with-deps chromium firefox webkit + - run: bun run check + - name: Run the fresh native libFLAC oracle + run: | + set -euo pipefail + codec_native_flac=$(scripts/build-native-flac.sh) + CODEC_NATIVE_FLAC="$codec_native_flac" bun run test:integration + - name: Install pinned Emscripten + run: | + set -euo pipefail + git clone --depth 1 --branch 6.0.9 https://github.com/emscripten-core/emsdk.git "$RUNNER_TEMP/codec-emsdk" + test "$(git -C "$RUNNER_TEMP/codec-emsdk" rev-parse HEAD)" = 5eb0bde7585670252e8ba05e9d361627bffd08b5 + "$RUNNER_TEMP/codec-emsdk/emsdk" install 6.0.9 + "$RUNNER_TEMP/codec-emsdk/emsdk" activate 6.0.9 + echo "CODEC_EMSCRIPTEN_ROOT=$RUNNER_TEMP/codec-emsdk/upstream/emscripten" >> "$GITHUB_ENV" + - run: bun run build:wasm:verify + - name: Build and qualify the release tarball + env: + CODEC_PACK_DESTINATION: ${{ runner.temp }}/codec-release + CODEC_PACK_RECEIPT: ${{ runner.temp }}/codec-pack-receipt.json + run: | + set -euo pipefail + mkdir "$CODEC_PACK_DESTINATION" + bun run pack:check + - name: Check the qualified artifact + id: artifact + env: + RELEASE_DIRECTORY: ${{ runner.temp }}/codec-release + RECEIPT_PATH: ${{ runner.temp }}/codec-pack-receipt.json + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { createHash } from "node:crypto"; + import { appendFile, readFile, readdir } from "node:fs/promises"; + import { join } from "node:path"; + const receipt = JSON.parse(await readFile(process.env.RECEIPT_PATH, "utf8")); + const entries = await readdir(process.env.RELEASE_DIRECTORY); + if (entries.length !== 1 || entries[0] !== receipt.tarballFilename) { + throw new Error("release directory does not contain exactly the tested tarball"); + } + const bytes = await readFile(join(process.env.RELEASE_DIRECTORY, entries[0])); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if (sha256 !== receipt.tarballSha256 || integrity !== receipt.tarballIntegrity) { + throw new Error("qualified tarball does not match its receipt"); + } + await appendFile( + process.env.GITHUB_OUTPUT, + `filename=${receipt.tarballFilename}\nsha256=${sha256}\nintegrity=${integrity}\n`, + ); + NODE + mkdir "$RUNNER_TEMP/npm-artifact" + cp "$RELEASE_DIRECTORY"/*.tgz "$RUNNER_TEMP/npm-artifact/" + cp "$RECEIPT_PATH" "$RUNNER_TEMP/npm-artifact/pack-receipt.json" + - run: git diff --check && git diff --exit-code + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: qualified-npm-package + path: ${{ runner.temp }}/npm-artifact + if-no-files-found: error + retention-days: 1 + + publish-or-compare: + needs: qualify + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + env: + NODE_AUTH_TOKEN: "" + NPM_AUTH_TOKEN: "" + NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/npm-globalrc + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/npm-userrc + NPM_TOKEN: "" + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.23.2 + - name: Select the pinned npm without a global install + run: | + set -euo pipefail + touch "$NPM_CONFIG_GLOBALCONFIG" "$NPM_CONFIG_USERCONFIG" + npm_bin=$(npm exec --yes --package="npm@$PINNED_NPM_VERSION" -- which npm) + test "$("$npm_bin" --version)" = "$PINNED_NPM_VERSION" + echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: qualified-npm-package + path: ${{ runner.temp }}/qualified-npm-package + - name: Revalidate the immutable qualified artifact + env: + ARTIFACT_DIRECTORY: ${{ runner.temp }}/qualified-npm-package + DISPATCH_VERSION: ${{ inputs.expected_version }} + EXPECTED_FILENAME: ${{ needs.qualify.outputs.tarball_filename }} + EXPECTED_INTEGRITY: ${{ needs.qualify.outputs.tarball_integrity }} + EXPECTED_SHA256: ${{ needs.qualify.outputs.tarball_sha256 }} + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { spawnSync } from "node:child_process"; + import { createHash } from "node:crypto"; + import { readFile, readdir } from "node:fs/promises"; + import { join } from "node:path"; + const directory = process.env.ARTIFACT_DIRECTORY; + const entries = (await readdir(directory)).sort(); + if ( + entries.length !== 2 || + !entries.includes("pack-receipt.json") || + !entries.includes(process.env.EXPECTED_FILENAME) + ) { + throw new Error("downloaded artifact has unexpected contents"); + } + const receipt = JSON.parse( + await readFile(join(directory, "pack-receipt.json"), "utf8"), + ); + const tarball = join(directory, process.env.EXPECTED_FILENAME); + const bytes = await readFile(tarball); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if ( + receipt.tarballFilename !== process.env.EXPECTED_FILENAME || + receipt.tarballSha256 !== process.env.EXPECTED_SHA256 || + receipt.tarballIntegrity !== process.env.EXPECTED_INTEGRITY || + sha256 !== process.env.EXPECTED_SHA256 || + integrity !== process.env.EXPECTED_INTEGRITY + ) { + throw new Error("downloaded tarball or receipt differs from qualification"); + } + const packedJson = spawnSync( + "tar", + ["-xOf", tarball, "package/package.json"], + { encoding: "utf8" }, + ); + if (packedJson.status !== 0) throw new Error(packedJson.stderr); + const manifest = JSON.parse(packedJson.stdout); + if ( + manifest.name !== process.env.EXPECTED_PACKAGE || + manifest.version !== process.env.DISPATCH_VERSION || + manifest.repository?.type !== "git" || + manifest.repository?.url !== process.env.EXPECTED_REPOSITORY + ) { + throw new Error("downloaded tarball has an unexpected package identity"); + } + NODE + - name: Check registry version admission + env: + DISPATCH_MODE: ${{ inputs.mode }} + DISPATCH_VERSION: ${{ inputs.expected_version }} + run: | + set -euo pipefail + node --input-type=module <<'NODE' + const url = `https://registry.npmjs.org/${encodeURIComponent(process.env.EXPECTED_PACKAGE)}/${encodeURIComponent(process.env.DISPATCH_VERSION)}`; + let response; + try { + response = await fetch(url, { headers: { accept: "application/json" } }); + } catch (error) { + throw new Error(`npm registry version lookup failed`, { cause: error }); + } + if (response.status === 404) { + if (process.env.DISPATCH_MODE === "verify") { + throw new Error("verify mode requires the exact registry version to exist"); + } + process.stdout.write("Exact version is absent; publication is admitted.\n"); + } else if (!response.ok) { + throw new Error(`npm registry version lookup returned HTTP ${response.status}`); + } else if (process.env.DISPATCH_MODE === "publish") { + throw new Error("publish mode requires the exact registry version to be absent"); + } else { + const metadata = await response.json(); + if ( + metadata.name !== process.env.EXPECTED_PACKAGE || + metadata.version !== process.env.DISPATCH_VERSION + ) { + throw new Error("registry returned an unexpected version document"); + } + process.stdout.write("Exact version exists; read-only verification is admitted.\n"); + } + NODE + - name: Publish the exact qualified tarball through OIDC + if: inputs.mode == 'publish' + env: + TARBALL: ${{ runner.temp }}/qualified-npm-package/${{ needs.qualify.outputs.tarball_filename }} + run: npm publish "$TARBALL" --access public --ignore-scripts --provenance + - name: Compare the registry tarball with qualification + env: + DISPATCH_MODE: ${{ inputs.mode }} + DISPATCH_VERSION: ${{ inputs.expected_version }} + EXPECTED_INTEGRITY: ${{ needs.qualify.outputs.tarball_integrity }} + EXPECTED_SHA256: ${{ needs.qualify.outputs.tarball_sha256 }} + REGISTRY_TARBALL: ${{ runner.temp }}/registry-package.tgz + run: | + set -euo pipefail + node --input-type=module <<'NODE' + import { createHash } from "node:crypto"; + import { writeFile } from "node:fs/promises"; + const versionUrl = `https://registry.npmjs.org/${encodeURIComponent(process.env.EXPECTED_PACKAGE)}/${encodeURIComponent(process.env.DISPATCH_VERSION)}`; + let metadata; + for (let attempt = 1; attempt <= 24; attempt++) { + let response; + try { + response = await fetch(versionUrl, { + headers: { accept: "application/json" }, + }); + } catch (error) { + throw new Error("npm registry convergence lookup failed", { + cause: error, + }); + } + if (response.ok) { + metadata = await response.json(); + break; + } + if (response.status !== 404 || process.env.DISPATCH_MODE !== "publish") { + throw new Error( + `npm registry convergence lookup returned HTTP ${response.status}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + if (!metadata) throw new Error("published version did not converge in two minutes"); + if ( + metadata.name !== process.env.EXPECTED_PACKAGE || + metadata.version !== process.env.DISPATCH_VERSION || + metadata.repository?.type !== "git" || + metadata.repository?.url !== process.env.EXPECTED_REPOSITORY || + metadata.dist?.integrity !== process.env.EXPECTED_INTEGRITY + ) { + throw new Error("registry package metadata differs from qualification"); + } + const tarballUrl = new URL(metadata.dist.tarball); + if ( + tarballUrl.protocol !== "https:" || + tarballUrl.hostname !== "registry.npmjs.org" + ) { + throw new Error("registry returned an unexpected tarball URL"); + } + const tarballResponse = await fetch(tarballUrl); + if (!tarballResponse.ok) { + throw new Error( + `registry tarball download returned HTTP ${tarballResponse.status}`, + ); + } + const bytes = new Uint8Array(await tarballResponse.arrayBuffer()); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if ( + sha256 !== process.env.EXPECTED_SHA256 || + integrity !== process.env.EXPECTED_INTEGRITY + ) { + throw new Error("registry tarball differs from the qualified tarball"); + } + await writeFile(process.env.REGISTRY_TARBALL, bytes); + process.stdout.write(`registry tarball SHA-256 ${sha256}\n`); + NODE + + registry-consumer: + needs: [qualify, publish-or-compare] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + NODE_AUTH_TOKEN: "" + NPM_AUTH_TOKEN: "" + NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/npm-globalrc + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/npm-userrc + NPM_TOKEN: "" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.expected_sha }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.23.2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - name: Select the pinned npm without a global install + run: | + set -euo pipefail + touch "$NPM_CONFIG_GLOBALCONFIG" "$NPM_CONFIG_USERCONFIG" + npm_bin=$(npm exec --yes --package="npm@$PINNED_NPM_VERSION" -- which npm) + test "$("$npm_bin" --version)" = "$PINNED_NPM_VERSION" + echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" + - run: bun install --frozen-lockfile + - run: bunx playwright install --with-deps chromium firefox webkit + - name: Run the full consumer matrix against the registry tarball + env: + CODEC_PACK_EXPECTED_SHA256: ${{ needs.qualify.outputs.tarball_sha256 }} + CODEC_PACK_RECEIPT: ${{ runner.temp }}/registry-pack-receipt.json + CODEC_PACK_SOURCE: ${{ env.EXPECTED_PACKAGE }}@${{ inputs.expected_version }} + run: bun run pack:check + - name: Verify registry signatures and conditional provenance + env: + DISPATCH_MODE: ${{ inputs.mode }} + DISPATCH_VERSION: ${{ inputs.expected_version }} + run: | + set -euo pipefail + audit_directory="$RUNNER_TEMP/codec-signature-audit" + mkdir "$audit_directory" + cd "$audit_directory" + npm init --yes >/dev/null + npm install --ignore-scripts --save-exact "$EXPECTED_PACKAGE@$DISPATCH_VERSION" >/dev/null + npm audit signatures --json --include-attestations > audit-signatures.json + if test "$DISPATCH_MODE" = publish; then + node --input-type=module <<'NODE' + import { readFile } from "node:fs/promises"; + const report = JSON.parse( + await readFile("audit-signatures.json", "utf8"), + ); + const target = report.verified?.find( + ({ name, version }) => + name === process.env.EXPECTED_PACKAGE && + version === process.env.DISPATCH_VERSION, + ); + if ( + target?.attestations?.provenance?.predicateType !== + "https://slsa.dev/provenance/v1" + ) { + throw new Error("OIDC publication lacks a verified provenance attestation"); + } + NODE + else + echo "Verify mode was read-only and does not claim an OIDC publication." + fi diff --git a/README.md b/README.md index bb3bb78..eca8c91 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ files, media pipelines, delivery chunks, manifests, storage, and publication. See [package boundaries](docs/architecture.md) and the [engine web adapter integration contract](docs/adapter-integration.md). +Install the package with a supported Effect peer: + +```sh +bun add @misofm/codec effect@4.0.0-rc.115 +``` + ## Encoding `encodeFlac(source, sink, options)` accepts an Effect stream of signed, @@ -159,4 +165,4 @@ their process/host boundaries. The [server corpus benchmark](https://github.com/misofm/codec/tree/main/benchmark) records Wasm measurements separately from native libFLAC and validates every result against canonical PCM. -No mobile benchmark is included. The package has not been published to npm. +No mobile benchmark is included. diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 66e2522..c296157 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -1,14 +1,40 @@ import { spawnSync } from "node:child_process"; +import { COPYFILE_EXCL } from "node:constants"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, writeFile, cp, mkdir, rm } from "node:fs/promises"; +import { + cp, + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const scratch = await mkdtemp(join(tmpdir(), "misofm-codec-packed-")); const peers = ["4.0.0-rc.112", "4.0.0-rc.115"]; const receipts = []; +const packSource = process.env.CODEC_PACK_SOURCE; +const expectedSha256 = process.env.CODEC_PACK_EXPECTED_SHA256; +const destination = process.env.CODEC_PACK_DESTINATION + ? resolve(process.env.CODEC_PACK_DESTINATION) + : undefined; +const isWithin = (parent, target) => { + const fromParent = relative(parent, target); + return ( + fromParent === "" || + (!isAbsolute(fromParent) && + fromParent !== ".." && + !fromParent.startsWith(`..${sep}`)) + ); +}; const run = (command, args, cwd) => { const result = spawnSync(command, args, { cwd, @@ -24,10 +50,65 @@ const run = (command, args, cwd) => { }; try { - run("bun", ["run", "build"], root); + if (expectedSha256 && !/^[0-9a-f]{64}$/.test(expectedSha256)) { + throw new Error( + "CODEC_PACK_EXPECTED_SHA256 must be 64 lowercase hex digits", + ); + } + if (destination) { + const [rootPath, destinationPath, destinationStat] = await Promise.all([ + realpath(root), + realpath(destination), + stat(destination), + ]); + if (!destinationStat.isDirectory() || isWithin(rootPath, destinationPath)) { + throw new Error( + "CODEC_PACK_DESTINATION must be an existing directory outside the repository", + ); + } + if ((await readdir(destinationPath)).length !== 0) { + throw new Error("CODEC_PACK_DESTINATION must be empty"); + } + if (process.env.CODEC_PACK_RECEIPT) { + const receiptPath = resolve(process.env.CODEC_PACK_RECEIPT); + const receiptDirectory = await realpath(dirname(receiptPath)); + if (isWithin(destinationPath, receiptDirectory)) { + throw new Error( + "CODEC_PACK_RECEIPT must be outside CODEC_PACK_DESTINATION", + ); + } + } + } + + if (!packSource) run("bun", ["run", "build"], root); const [packed] = JSON.parse( - run("npm", ["pack", "--json", "--pack-destination", scratch], root), + run( + "npm", + [ + "pack", + packSource ?? root, + "--json", + "--ignore-scripts", + "--pack-destination", + scratch, + ], + root, + ), + ); + if (!packed?.filename || !Array.isArray(packed.files)) { + throw new Error("npm pack did not return one complete package description"); + } + const expectedPackage = JSON.parse( + await readFile(join(root, "package.json"), "utf8"), ); + if ( + packed.name !== expectedPackage.name || + packed.version !== expectedPackage.version + ) { + throw new Error( + `packed identity ${packed.name}@${packed.version} does not match ${expectedPackage.name}@${expectedPackage.version}`, + ); + } const names = packed.files.map((file) => file.path); for (const required of [ "dist/index.js", @@ -52,9 +133,32 @@ try { } } const tarball = join(scratch, packed.filename); - const hash = createHash("sha256") - .update(await readFile(tarball)) - .digest("hex"); + const tarballBytes = await readFile(tarball); + const hash = createHash("sha256").update(tarballBytes).digest("hex"); + const integrity = `sha512-${createHash("sha512") + .update(tarballBytes) + .digest("base64")}`; + if (packed.integrity && packed.integrity !== integrity) { + throw new Error( + `npm pack integrity ${packed.integrity} does not match computed ${integrity}`, + ); + } + if (expectedSha256 && expectedSha256 !== hash) { + throw new Error( + `packed tarball SHA-256 ${hash} does not match expected ${expectedSha256}`, + ); + } + const packedManifest = JSON.parse( + run("tar", ["-xOf", tarball, "package/package.json"], root), + ); + if ( + packedManifest.name !== expectedPackage.name || + packedManifest.version !== expectedPackage.version || + packedManifest.repository?.type !== expectedPackage.repository.type || + packedManifest.repository?.url !== expectedPackage.repository.url + ) { + throw new Error("packed package manifest has an unexpected identity"); + } for (const peer of peers) { const directory = join(scratch, peer); await mkdir(directory); @@ -137,7 +241,9 @@ try { ); } const report = { + tarballFilename: packed.filename, tarballSha256: hash, + tarballIntegrity: integrity, packedBytes: packed.size, unpackedBytes: packed.unpackedSize, files: names, @@ -149,6 +255,17 @@ try { `${JSON.stringify(report, null, 2)}\n`, ); } + if (destination) { + const retainedTarball = join(destination, packed.filename); + await copyFile(tarball, retainedTarball, COPYFILE_EXCL); + const retainedHash = createHash("sha256") + .update(await readFile(retainedTarball)) + .digest("hex"); + if (retainedHash !== hash) { + await rm(retainedTarball, { force: true }); + throw new Error("retained tarball differs from the tested tarball"); + } + } process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } finally { await rm(scratch, { recursive: true, force: true }); From 5d7e1a7ceb667276c84934e0a61e95c725e5cae6 Mon Sep 17 00:00:00 2001 From: BL Date: Sun, 13 Sep 2026 05:34:33 +0000 Subject: [PATCH 2/2] fix: initialize release npm config in runner steps --- .github/workflows/npm-publish.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b2a2ecd..39a0003 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -164,8 +164,6 @@ jobs: env: NODE_AUTH_TOKEN: "" NPM_AUTH_TOKEN: "" - NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/npm-globalrc - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/npm-userrc NPM_TOKEN: "" steps: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -174,10 +172,14 @@ jobs: - name: Select the pinned npm without a global install run: | set -euo pipefail + export NPM_CONFIG_GLOBALCONFIG="$RUNNER_TEMP/npm-globalrc" + export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npm-userrc" touch "$NPM_CONFIG_GLOBALCONFIG" "$NPM_CONFIG_USERCONFIG" npm_bin=$(npm exec --yes --package="npm@$PINNED_NPM_VERSION" -- which npm) test "$("$npm_bin" --version)" = "$PINNED_NPM_VERSION" echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" + echo "NPM_CONFIG_GLOBALCONFIG=$NPM_CONFIG_GLOBALCONFIG" >> "$GITHUB_ENV" + echo "NPM_CONFIG_USERCONFIG=$NPM_CONFIG_USERCONFIG" >> "$GITHUB_ENV" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: qualified-npm-package @@ -356,8 +358,6 @@ jobs: env: NODE_AUTH_TOKEN: "" NPM_AUTH_TOKEN: "" - NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/npm-globalrc - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/npm-userrc NPM_TOKEN: "" steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -373,10 +373,14 @@ jobs: - name: Select the pinned npm without a global install run: | set -euo pipefail + export NPM_CONFIG_GLOBALCONFIG="$RUNNER_TEMP/npm-globalrc" + export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npm-userrc" touch "$NPM_CONFIG_GLOBALCONFIG" "$NPM_CONFIG_USERCONFIG" npm_bin=$(npm exec --yes --package="npm@$PINNED_NPM_VERSION" -- which npm) test "$("$npm_bin" --version)" = "$PINNED_NPM_VERSION" echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" + echo "NPM_CONFIG_GLOBALCONFIG=$NPM_CONFIG_GLOBALCONFIG" >> "$GITHUB_ENV" + echo "NPM_CONFIG_USERCONFIG=$NPM_CONFIG_USERCONFIG" >> "$GITHUB_ENV" - run: bun install --frozen-lockfile - run: bunx playwright install --with-deps chromium firefox webkit - name: Run the full consumer matrix against the registry tarball