From b649980b10c6a306c6bb9893717738ff4d8cfb30 Mon Sep 17 00:00:00 2001 From: zancas Date: Mon, 12 Oct 2026 16:13:29 -0700 Subject: [PATCH 1/5] feat(bindings): publish the Bundle Pin scripts as the npm package @zingolabs/bundle-pin zingolib#2930 rules that the scripts a consumer runs to hold its Bundle Pin and fetch the Prebuilt Bundles are defined in one place. That place is bindings/pin, beside the Android and Swift packaging, so the scripts and the bundle layout they read change in one pull request. The package carries the modules kitchen-sync proved, with two commands, pin-binding-layer and fetch-binding-layer, that take the consumer root from the working directory. It depends on nothing, and its tests run on the runner Node ships. The Binding Layer publish workflow publishes the package by npm trusted publishing when npm lacks the version the package names, and a new check fails a pull request that changes the package without raising that version. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/binding-layer-publish.yaml | 26 +++ .github/workflows/bundle-pin.yaml | 64 ++++++++ bindings/pin/README.md | 60 +++++++ bindings/pin/bin/fetch_binding_layer.mjs | 33 ++++ bindings/pin/bin/pin_binding_layer.mjs | 44 +++++ bindings/pin/lib/bundle_messages.mjs | 62 ++++++++ bindings/pin/lib/bundle_pin.mjs | 109 +++++++++++++ bindings/pin/lib/fetch_binding_layer.mjs | 87 ++++++++++ bindings/pin/lib/pin_file.mjs | 20 +++ bindings/pin/lib/registry.mjs | 97 +++++++++++ bindings/pin/lib/swift_package.mjs | 58 +++++++ bindings/pin/package.json | 26 +++ bindings/pin/test/bundle_pin.test.mjs | 159 +++++++++++++++++++ bindings/pin/test/swift_package.test.mjs | 66 ++++++++ 14 files changed, 911 insertions(+) create mode 100644 .github/workflows/bundle-pin.yaml create mode 100644 bindings/pin/README.md create mode 100755 bindings/pin/bin/fetch_binding_layer.mjs create mode 100755 bindings/pin/bin/pin_binding_layer.mjs create mode 100644 bindings/pin/lib/bundle_messages.mjs create mode 100644 bindings/pin/lib/bundle_pin.mjs create mode 100644 bindings/pin/lib/fetch_binding_layer.mjs create mode 100644 bindings/pin/lib/pin_file.mjs create mode 100644 bindings/pin/lib/registry.mjs create mode 100644 bindings/pin/lib/swift_package.mjs create mode 100644 bindings/pin/package.json create mode 100644 bindings/pin/test/bundle_pin.test.mjs create mode 100644 bindings/pin/test/swift_package.test.mjs diff --git a/.github/workflows/binding-layer-publish.yaml b/.github/workflows/binding-layer-publish.yaml index 92fbafa6bb..f899b0a0ca 100644 --- a/.github/workflows/binding-layer-publish.yaml +++ b/.github/workflows/binding-layer-publish.yaml @@ -29,8 +29,12 @@ on: # The reactions on the comment go through the issue-comment endpoint, # which the token's `issues` permission gates. +# The npm publish of bindings/pin signs its provenance with the job's OIDC +# token, which `id-token` grants; npm's trusted publishing for this +# repository and workflow takes the place of a stored npm token. permissions: contents: write + id-token: write issues: write packages: write pull-requests: write @@ -249,6 +253,28 @@ jobs: cargo run --quiet --manifest-path tools/workbench/Cargo.toml \ --bin binding-publish -- --commit "$COMMIT" --bundles "$BUNDLES" + # bindings/pin is the npm package every consumer installs to hold its + # Bundle Pin. It publishes with the Binding Layer, once per version: + # the step publishes nothing when npm already holds the version the + # package names. Trusted publishing needs an npm newer than the one + # Node 22 bundles. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Publish the Bundle Pin package when npm lacks its version + working-directory: bindings/pin + run: | + npm install --global npm@latest + version="$(node --print "require('./package.json').version")" + if npm view "@zingolabs/bundle-pin@${version}" version >/dev/null 2>&1; then + echo "npm holds @zingolabs/bundle-pin@${version}." + else + npm publish --access public --provenance + fi + # A failed build leaves the tool refusing in the step above, and a # failed push or record fails there too; either way the comment is # the only place the outcome can show, so one reaction reports it. diff --git a/.github/workflows/bundle-pin.yaml b/.github/workflows/bundle-pin.yaml new file mode 100644 index 0000000000..9e996d20c5 --- /dev/null +++ b/.github/workflows/bundle-pin.yaml @@ -0,0 +1,64 @@ +# bindings/pin is the npm package @zingolabs/bundle-pin, which consumers of +# the Binding Layer install to hold their Bundle Pin and fetch the Prebuilt +# Bundles. The package has no dependency, so the tests run on Node's own +# runner with no install. The publish workflow publishes the package when +# its version is absent from npm, so a change to the package must raise the +# version, and the second job fails when it does not. +# +# The check has its own workflow with no `paths` filter, for the reason +# no-duplicated-code.yaml states: a required check must report on every pull +# request. +name: Bundle Pin package + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [stable, dev] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + bundle-pin: + name: Bundle Pin package + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + working-directory: bindings/pin + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run the tests + run: node --test test/*.test.mjs + + - name: Check the published files + run: npm pack --dry-run + + # A pull request that changes the package must name a version npm + # does not hold, or the publish workflow would publish nothing. + - name: Check that a changed package raises its version + if: ${{ github.event_name == 'pull_request' }} + env: + BASE: origin/${{ github.base_ref }} + run: | + if git diff --quiet "$BASE" -- bin lib package.json; then + echo "The package is unchanged." + exit 0 + fi + version="$(node --print "require('./package.json').version")" + if npm view "@zingolabs/bundle-pin@${version}" version >/dev/null 2>&1; then + echo "npm already holds @zingolabs/bundle-pin@${version}; raise the version in bindings/pin/package.json." + exit 1 + fi + echo "@zingolabs/bundle-pin@${version} is unpublished." diff --git a/bindings/pin/README.md b/bindings/pin/README.md new file mode 100644 index 0000000000..91e534b4a3 --- /dev/null +++ b/bindings/pin/README.md @@ -0,0 +1,60 @@ +# @zingolabs/bundle-pin + +The package that a consumer of the Binding Layer installs to hold its Bundle +Pin and to fetch the pinned Prebuilt Bundles. zingolib publishes it to npm +from this directory, so every consumer runs one definition of the scripts, and +the scripts and the bundle layout they read change in one pull request. + +## Install + +```sh +yarn add --dev @zingolabs/bundle-pin +``` + +The package depends on nothing beyond Node 20 and `tar`. + +## The Bundle Pin + +`binding-layer.json` at the consumer's root names one zingolib commit and the +digest of that commit's Prebuilt Bundle for each platform the consumer uses: + +```json +{ + "commit": "<40 hexadecimal characters>", + "digests": { + "android": "sha256:<64 hexadecimal characters>", + "ios": "sha256:<64 hexadecimal characters>" + } +} +``` + +## Commands + +Both commands run from the consumer's root. `--root ` names another root, +and `--pin ` another pin file. + +`pin-binding-layer [--platform ]` moves the pin. It asks +the registry for the digest of each pinned platform's bundle at that commit +and rewrites the pin for every platform or for none. The first move names the +platforms with `--platform`. + +`fetch-binding-layer --platform [--check-tag]` prints the path of +the pinned bundle, downloading it first when the checkout does not hold it. +Android bundles land under `android/build/binding-layer//`. The iOS +bundle is extracted to `ios/binding-layer/ZingoBindings`, beside a marker file +that names its digest, and the command prints that package path. With +`--check-tag` the command also verifies that the registry's tag for the pinned +commit still resolves to the pinned digest. + +## Consumers + +A Gradle build runs the fetch and adds the printed AAR as a file dependency. +An Xcode project references `ios/binding-layer/ZingoBindings` as a local +package. The consumer's CI fetches each platform in its own job with +`--check-tag`. + +## Tests + +```sh +node --test test/*.test.mjs +``` diff --git a/bindings/pin/bin/fetch_binding_layer.mjs b/bindings/pin/bin/fetch_binding_layer.mjs new file mode 100755 index 0000000000..c1e5fa08c1 --- /dev/null +++ b/bindings/pin/bin/fetch_binding_layer.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Prints the path of the pinned Prebuilt Bundle for one platform, downloading +// it first when the checkout does not hold it. For iOS it prints the path of +// the ZingoBindings package. With --check-tag it also verifies that the +// registry's tag for the pinned commit resolves to the pinned digest. +// +// Usage, from the consumer's root: fetch-binding-layer --platform android [--check-tag] + +import { join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { failureMessage } from '../lib/bundle_messages.mjs'; +import { pinnedBundle } from '../lib/fetch_binding_layer.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + root: { type: 'string', default: process.cwd() }, + pin: { type: 'string' }, + store: { type: 'string' }, + 'check-tag': { type: 'boolean', default: false }, + }, +}); +const root = resolve(values.root); +const outcome = await pinnedBundle(root, { + ...values, + pin: values.pin ?? join(root, 'binding-layer.json'), +}); +if (outcome.kind === 'fetched') { + process.stdout.write(`${outcome.path}\n`); +} else { + process.stderr.write(`${failureMessage(outcome)}\n`); + process.exitCode = 1; +} diff --git a/bindings/pin/bin/pin_binding_layer.mjs b/bindings/pin/bin/pin_binding_layer.mjs new file mode 100755 index 0000000000..9bd25f1d75 --- /dev/null +++ b/bindings/pin/bin/pin_binding_layer.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Moves the Bundle Pin to one zingolib commit. It asks the registry for the +// digest of each pinned platform's bundle and rewrites binding-layer.json, +// for every platform or for none. +// +// Usage, from the consumer's root: pin-binding-layer [--platform ] + +import { join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { failureMessage } from '../lib/bundle_messages.mjs'; +import { movePin, repository } from '../lib/bundle_pin.mjs'; +import { readPin, writePin } from '../lib/pin_file.mjs'; +import { resolveBundle } from '../lib/registry.mjs'; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + platform: { type: 'string', multiple: true, default: [] }, + root: { type: 'string', default: process.cwd() }, + pin: { type: 'string' }, + }, +}); +const pinPath = values.pin ?? join(resolve(values.root), 'binding-layer.json'); +const current = await readPin(pinPath); +const outcome = + current.kind === 'malformedPin' + ? current + : await movePin( + current, + positionals[0] ?? '', + values.platform, + (platform, commit) => resolveBundle(repository(platform), commit), + ); +if (outcome.kind !== 'moved') { + process.stderr.write(`${failureMessage(outcome)}\n`); + process.exitCode = 1; +} else { + await writePin(pinPath, outcome.pin); + for (const [platform, digest] of Object.entries(outcome.pin.digests)) { + process.stdout.write( + `${platform} ${outcome.descriptors[platform]} ${digest}\n`, + ); + } +} diff --git a/bindings/pin/lib/bundle_messages.mjs b/bindings/pin/lib/bundle_messages.mjs new file mode 100644 index 0000000000..e93155a1d1 --- /dev/null +++ b/bindings/pin/lib/bundle_messages.mjs @@ -0,0 +1,62 @@ +// The message that a person reads for each failure of the Bundle Pin scripts. + +import { PLATFORMS, reference, REGISTRY, repository } from './bundle_pin.mjs'; + +const REPORT = 'Report this to zingolib.'; + +export function failureMessage(failure) { + switch (failure.kind) { + case 'badCommit': + return `"${failure.commit}" is not a full zingolib commit. Give all 40 hexadecimal characters.`; + case 'noPlatform': + return 'The Bundle Pin lists no platform. Add one with --platform android.'; + case 'unknownPlatform': + return `"${ + failure.platform + }" is not a platform. The platforms are ${PLATFORMS.join(' and ')}.`; + case 'absent': + return ( + `zingolib has published no ${failure.platform} Prebuilt Bundle for commit ${failure.commit}. ` + + `The registry holds no ${reference( + failure.platform, + failure.commit, + )}. ` + + 'Request the publish in zingolib, or pin a published commit with "yarn pin ".' + ); + case 'refused': + return ( + `The registry refuses anonymous access to ${REGISTRY}/${repository( + failure.platform, + )}. ` + + `zingolib has published no ${failure.platform} Prebuilt Bundle, or its package is private.` + ); + case 'wrongRevision': + return ( + `The ${failure.platform} bundle tagged ${failure.commit} reports the commit ${failure.revision}. ` + + REPORT + ); + case 'movedTag': + return ( + `The tag ${reference( + failure.platform, + failure.commit, + )} now resolves to ${failure.found}, ` + + `and the Bundle Pin holds ${failure.pinned}. A published tag never moves. ${REPORT}` + ); + case 'malformedPin': + return `binding-layer.json holds no valid ${failure.field}. Write the file with "yarn pin ".`; + case 'unpinned': + return 'This checkout has no binding-layer.json. Create it with "yarn pin --platform android".'; + case 'unpinnedPlatform': + return ( + `The Bundle Pin holds no ${failure.platform} digest. ` + + `Add it with "yarn pin --platform ${failure.platform}".` + ); + case 'badLayout': + return `The ${failure.platform} bundle is not one named file in one layer. ${REPORT}`; + case 'corrupt': + return `The downloaded content hashes to ${failure.found}, and the bundle's layer is ${failure.expected}.`; + case 'badPackage': + return `The ${failure.platform} bundle holds no ZingoBindings/Package.swift. ${REPORT}`; + } +} diff --git a/bindings/pin/lib/bundle_pin.mjs b/bindings/pin/lib/bundle_pin.mjs new file mode 100644 index 0000000000..eda42a42f6 --- /dev/null +++ b/bindings/pin/lib/bundle_pin.mjs @@ -0,0 +1,109 @@ +// The Bundle Pin: one zingolib commit, and one digest per platform. Every +// function here decides from its arguments alone. + +export const REGISTRY = 'ghcr.io'; +export const PLATFORMS = ['android', 'ios']; + +const COMMIT = /^[0-9a-f]{40}$/; +const DIGEST = /^sha256:[0-9a-f]{64}$/; + +export function repository(platform) { + return `zingolabs/zingolib/binding-layer-${platform}`; +} + +export function reference(platform, commit) { + return `${REGISTRY}/${repository(platform)}:${commit}`; +} + +export function parsePin(text) { + const { commit, digests } = JSON.parse(text); + if (!COMMIT.test(commit)) { + return { kind: 'malformedPin', field: 'commit' }; + } + const platforms = Object.keys(digests ?? {}); + const bad = platforms.find( + platform => + !PLATFORMS.includes(platform) || !DIGEST.test(digests[platform]), + ); + if (platforms.length === 0 || bad) { + return { kind: 'malformedPin', field: bad ? `digests.${bad}` : 'digests' }; + } + return { kind: 'pin', commit, digests }; +} + +export function serializePin({ commit, digests }) { + const sorted = Object.fromEntries( + Object.entries(digests).sort(([left], [right]) => + left.localeCompare(right), + ), + ); + return `${JSON.stringify({ commit, digests: sorted }, null, 2)}\n`; +} + +export function checkTag(platform, commit, pinned, found) { + if (found.kind !== 'bundle') { + return { kind: found.kind, platform, commit }; + } + if (found.digest !== pinned) { + return { kind: 'movedTag', platform, commit, pinned, found: found.digest }; + } + return { kind: 'agrees' }; +} + +function judge(current, platform, commit, found) { + if (found.kind !== 'bundle') { + return { kind: found.kind, platform, commit }; + } + if (found.revision !== commit) { + return { + kind: 'wrongRevision', + platform, + commit, + revision: found.revision, + }; + } + const pinned = current.kind === 'pin' && current.commit === commit; + if (pinned && current.digests[platform]) { + return checkTag(platform, commit, current.digests[platform], found); + } + return { kind: 'agrees' }; +} + +export async function movePin(current, commit, added, resolve) { + if (!COMMIT.test(commit)) { + return { kind: 'badCommit', commit }; + } + const held = current.kind === 'pin' ? Object.keys(current.digests) : []; + const platforms = [...new Set([...held, ...added])]; + if (platforms.length === 0) { + return { kind: 'noPlatform' }; + } + const unknown = platforms.find(platform => !PLATFORMS.includes(platform)); + if (unknown) { + return { kind: 'unknownPlatform', platform: unknown }; + } + const bundles = await Promise.all( + platforms.map(async platform => ({ + platform, + found: await resolve(platform, commit), + })), + ); + const failure = bundles + .map(({ platform, found }) => judge(current, platform, commit, found)) + .find(judged => judged.kind !== 'agrees'); + if (failure) { + return failure; + } + return { + kind: 'moved', + pin: { + commit, + digests: Object.fromEntries( + bundles.map(({ platform, found }) => [platform, found.digest]), + ), + }, + descriptors: Object.fromEntries( + bundles.map(({ platform, found }) => [platform, found.descriptor]), + ), + }; +} diff --git a/bindings/pin/lib/fetch_binding_layer.mjs b/bindings/pin/lib/fetch_binding_layer.mjs new file mode 100644 index 0000000000..1091de5720 --- /dev/null +++ b/bindings/pin/lib/fetch_binding_layer.mjs @@ -0,0 +1,87 @@ +// The pinned Prebuilt Bundle of one platform: fetchBundle downloads it into +// the store when the store lacks it, and pinnedBundle places the iOS package +// under /ios/binding-layer where the Xcode project references it. + +import { mkdir, readdir, rename } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { checkTag, PLATFORMS, repository } from './bundle_pin.mjs'; +import { readPin } from './pin_file.mjs'; +import { download, resolveBundle } from './registry.mjs'; +import { placeSwiftPackage } from './swift_package.mjs'; + +async function held(home) { + const files = await readdir(home).catch(failure => { + if (failure.code === 'ENOENT') { + return []; + } + throw failure; + }); + return files.length === 1 + ? { kind: 'held', path: join(home, files[0]) } + : { kind: 'missing' }; +} + +export async function fetchBundle(pin, platform, store, always, registry) { + const pinned = pin.digests[platform]; + if (!pinned) { + return { kind: 'unpinnedPlatform', platform }; + } + const home = join(store, pinned.slice('sha256:'.length)); + const disk = await held(home); + if (disk.kind === 'held' && !always) { + return { kind: 'fetched', path: disk.path }; + } + const found = await registry.resolveBundle(repository(platform), pin.commit); + const tag = checkTag(platform, pin.commit, pinned, found); + if (tag.kind !== 'agrees') { + return tag; + } + if (disk.kind === 'held') { + return { kind: 'fetched', path: disk.path }; + } + const [layer] = found.layers; + if (found.layers.length !== 1 || !layer.file) { + return { kind: 'badLayout', platform }; + } + const partial = `${home}.partial`; + const file = basename(layer.file); + await mkdir(partial, { recursive: true }); + const blob = await registry.download( + repository(platform), + layer.digest, + join(partial, file), + ); + if (blob.kind !== 'downloaded') { + return blob; + } + await rename(partial, home); + return { kind: 'fetched', path: join(home, file) }; +} + +export async function pinnedBundle(root, values) { + if (!PLATFORMS.includes(values.platform)) { + return { kind: 'unknownPlatform', platform: values.platform }; + } + const pin = await readPin(values.pin); + if (pin.kind !== 'pin') { + return pin; + } + const fetched = await fetchBundle( + pin, + values.platform, + values.store ?? join(root, values.platform, 'build', 'binding-layer'), + values['check-tag'], + { resolveBundle, download }, + ); + if (fetched.kind !== 'fetched' || values.platform !== 'ios') { + return fetched; + } + const placed = await placeSwiftPackage( + fetched.path, + pin.digests.ios, + join(root, 'ios', 'binding-layer'), + ); + return placed.kind === 'placed' + ? { kind: 'fetched', path: placed.path } + : placed; +} diff --git a/bindings/pin/lib/pin_file.mjs b/bindings/pin/lib/pin_file.mjs new file mode 100644 index 0000000000..97a24b08c2 --- /dev/null +++ b/bindings/pin/lib/pin_file.mjs @@ -0,0 +1,20 @@ +// The Bundle Pin's file: binding-layer.json. + +import { readFile, rename, writeFile } from 'node:fs/promises'; +import { parsePin, serializePin } from './bundle_pin.mjs'; + +export async function readPin(path) { + const text = await readFile(path, 'utf8').catch(failure => { + if (failure.code === 'ENOENT') { + return undefined; + } + throw failure; + }); + return text === undefined ? { kind: 'unpinned' } : parsePin(text); +} + +export async function writePin(path, pin) { + const partial = `${path}.partial`; + await writeFile(partial, serializePin(pin)); + await rename(partial, path); +} diff --git a/bindings/pin/lib/registry.mjs b/bindings/pin/lib/registry.mjs new file mode 100644 index 0000000000..560f52280b --- /dev/null +++ b/bindings/pin/lib/registry.mjs @@ -0,0 +1,97 @@ +// The registry requests of the Bundle Pin scripts: a token, a manifest, and a +// layer's content. + +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { REGISTRY } from './bundle_pin.mjs'; + +const MANIFEST = 'application/vnd.oci.image.manifest.v1+json'; +const REVISION = 'org.opencontainers.image.revision'; +const DESCRIPTOR = 'org.zingolabs.zingolib.descriptor'; +const TITLE = 'org.opencontainers.image.title'; +const REFUSALS = [401, 403]; +const NOT_FOUND = [404]; + +function sha256(hash) { + return `sha256:${hash.digest('hex')}`; +} + +async function request(url, headers, tolerated) { + const reply = await fetch(url, { headers }); + if (!reply.ok && !tolerated.includes(reply.status)) { + throw new Error(`${url} answered ${reply.status}`); + } + return reply; +} + +async function access(repository) { + const reply = await request( + `https://${REGISTRY}/token?service=${REGISTRY}&scope=repository:${repository}:pull`, + {}, + REFUSALS, + ); + if (!reply.ok) { + return { kind: 'refused' }; + } + const { token } = await reply.json(); + return { kind: 'granted', headers: { Authorization: `Bearer ${token}` } }; +} + +export async function resolveBundle(repository, tag) { + const granted = await access(repository); + if (granted.kind === 'refused') { + return granted; + } + const reply = await request( + `https://${REGISTRY}/v2/${repository}/manifests/${tag}`, + { ...granted.headers, Accept: MANIFEST }, + NOT_FOUND, + ); + if (!reply.ok) { + return { kind: 'absent' }; + } + const body = Buffer.from(await reply.arrayBuffer()); + const { annotations = {}, layers } = JSON.parse(body); + return { + kind: 'bundle', + digest: sha256(createHash('sha256').update(body)), + revision: annotations[REVISION], + descriptor: annotations[DESCRIPTOR], + layers: layers.map(layer => ({ + digest: layer.digest, + file: layer.annotations?.[TITLE], + })), + }; +} + +export async function download(repository, digest, path) { + const granted = await access(repository); + if (granted.kind === 'refused') { + throw new Error(`${REGISTRY} refused access to ${repository}`); + } + const reply = await request( + `https://${REGISTRY}/v2/${repository}/blobs/${digest}`, + granted.headers, + [], + ); + const hash = createHash('sha256'); + await pipeline( + Readable.fromWeb(reply.body), + async function* (chunks) { + for await (const chunk of chunks) { + hash.update(chunk); + yield chunk; + } + }, + createWriteStream(path), + ); + const found = sha256(hash); + if (found !== digest) { + await rm(path); + return { kind: 'corrupt', expected: digest, found }; + } + return { kind: 'downloaded', path }; +} diff --git a/bindings/pin/lib/swift_package.mjs b/bindings/pin/lib/swift_package.mjs new file mode 100644 index 0000000000..ff4f9000fb --- /dev/null +++ b/bindings/pin/lib/swift_package.mjs @@ -0,0 +1,58 @@ +import { execFile } from 'node:child_process'; +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +export const PACKAGE = 'ZingoBindings'; +export const MARKER = 'digest'; +const MANIFEST = 'Package.swift'; + +export function placement(marker, digest, manifestPresent) { + return marker === digest && manifestPresent ? 'held' : 'extract'; +} + +async function text(path) { + return readFile(path, 'utf8') + .then(content => content.trim()) + .catch(failure => { + if (failure.code === 'ENOENT') { + return undefined; + } + throw failure; + }); +} + +async function exists(path) { + return stat(path) + .then(() => true) + .catch(failure => { + if (failure.code === 'ENOENT') { + return false; + } + throw failure; + }); +} + +export async function untar(archive, into) { + await promisify(execFile)('tar', ['-xzf', archive, '-C', into]); +} + +export async function placeSwiftPackage(archive, digest, home, extract = untar) { + const manifest = join(home, PACKAGE, MANIFEST); + const marker = await text(join(home, MARKER)); + if (placement(marker, digest, await exists(manifest)) === 'held') { + return { kind: 'placed', path: join(home, PACKAGE) }; + } + const partial = `${home}.partial`; + await rm(partial, { recursive: true, force: true }); + await mkdir(partial, { recursive: true }); + await extract(archive, partial); + if (!(await exists(join(partial, PACKAGE, MANIFEST)))) { + await rm(partial, { recursive: true, force: true }); + return { kind: 'badPackage', platform: 'ios' }; + } + await writeFile(join(partial, MARKER), `${digest}\n`); + await rm(home, { recursive: true, force: true }); + await rename(partial, home); + return { kind: 'placed', path: join(home, PACKAGE) }; +} diff --git a/bindings/pin/package.json b/bindings/pin/package.json new file mode 100644 index 0000000000..11c218fec3 --- /dev/null +++ b/bindings/pin/package.json @@ -0,0 +1,26 @@ +{ + "name": "@zingolabs/bundle-pin", + "version": "0.1.0", + "description": "Pins and fetches zingolib's published Prebuilt Bundles for a consumer of the Binding Layer.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/zingolabs/zingolib.git", + "directory": "bindings/pin" + }, + "type": "module", + "bin": { + "fetch-binding-layer": "bin/fetch_binding_layer.mjs", + "pin-binding-layer": "bin/pin_binding_layer.mjs" + }, + "files": [ + "bin", + "lib" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test test/*.test.mjs" + } +} diff --git a/bindings/pin/test/bundle_pin.test.mjs b/bindings/pin/test/bundle_pin.test.mjs new file mode 100644 index 0000000000..3effbb0c7a --- /dev/null +++ b/bindings/pin/test/bundle_pin.test.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mock, test } from 'node:test'; +import { movePin, parsePin, serializePin } from '../lib/bundle_pin.mjs'; +import { fetchBundle } from '../lib/fetch_binding_layer.mjs'; + +const COMMIT = 'a'.repeat(40); +const NEXT = 'b'.repeat(40); +const ANDROID = `sha256:${'1'.repeat(64)}`; +const IOS = `sha256:${'2'.repeat(64)}`; +const LAYER = `sha256:${'3'.repeat(64)}`; +const FILE = 'binding-layer.aar'; + +const pin = { kind: 'pin', commit: COMMIT, digests: { android: ANDROID } }; + +function bundle(digest, revision) { + return { + kind: 'bundle', + digest, + revision, + descriptor: 'zl_3.0.0_aaaaa', + layers: [{ digest: LAYER, file: FILE }], + }; +} + +function registry(found) { + return { + resolveBundle: mock.fn(async () => found), + download: mock.fn(async (_repository, _digest, path) => { + await writeFile(path, 'bundle'); + return { kind: 'downloaded', path }; + }), + }; +} + +async function store() { + return mkdtemp(join(tmpdir(), 'bundle-pin-store-')); +} + +async function storeWithBundle() { + const root = await store(); + const home = join(root, ANDROID.slice('sha256:'.length)); + await mkdir(home); + await writeFile(join(home, FILE), 'bundle'); + return { root, path: join(home, FILE) }; +} + +test('parsePin reads the pin that serializePin wrote', () => { + const written = { commit: COMMIT, digests: { ios: IOS, android: ANDROID } }; + assert.deepEqual(parsePin(serializePin(written)), { kind: 'pin', ...written }); +}); + +test('serializePin writes the commit first and the platforms in order', () => { + assert.equal( + serializePin({ commit: COMMIT, digests: { ios: IOS, android: ANDROID } }), + `{\n "commit": "${COMMIT}",\n "digests": {\n "android": "${ANDROID}",\n "ios": "${IOS}"\n }\n}\n`, + ); +}); + +test('parsePin names the commit when the commit is abbreviated', () => { + const text = JSON.stringify({ commit: 'aaaaa', digests: { android: ANDROID } }); + assert.deepEqual(parsePin(text), { kind: 'malformedPin', field: 'commit' }); +}); + +test('parsePin names the platform when its digest is malformed', () => { + const text = JSON.stringify({ commit: COMMIT, digests: { android: 'sha256:1' } }); + assert.deepEqual(parsePin(text), { kind: 'malformedPin', field: 'digests.android' }); +}); + +test('movePin moves every listed platform when each has a bundle for the commit', async () => { + const current = { ...pin, digests: { android: ANDROID, ios: IOS } }; + const moved = await movePin(current, NEXT, [], async platform => + bundle(platform === 'android' ? IOS : ANDROID, NEXT), + ); + assert.deepEqual(moved.pin, { commit: NEXT, digests: { android: IOS, ios: ANDROID } }); +}); + +test('movePin returns no pin when one listed platform has no bundle for the commit', async () => { + const current = { ...pin, digests: { android: ANDROID, ios: IOS } }; + const moved = await movePin(current, NEXT, [], async platform => + platform === 'ios' ? { kind: 'absent' } : bundle(ANDROID, NEXT), + ); + assert.deepEqual(moved, { kind: 'absent', platform: 'ios', commit: NEXT }); +}); + +test('movePin returns no pin when the registry refuses access to a platform', async () => { + const moved = await movePin(pin, NEXT, [], async () => ({ kind: 'refused' })); + assert.deepEqual(moved, { kind: 'refused', platform: 'android', commit: NEXT }); +}); + +test('movePin reports a moved tag when the pinned commit resolves to another digest', async () => { + const moved = await movePin(pin, COMMIT, [], async () => bundle(IOS, COMMIT)); + assert.deepEqual(moved, { + kind: 'movedTag', + platform: 'android', + commit: COMMIT, + pinned: ANDROID, + found: IOS, + }); +}); + +test("movePin reports the bundle's own commit when it differs from the tag", async () => { + const moved = await movePin(pin, NEXT, [], async () => bundle(ANDROID, COMMIT)); + assert.deepEqual(moved, { + kind: 'wrongRevision', + platform: 'android', + commit: NEXT, + revision: COMMIT, + }); +}); + +test('movePin adds a platform when the flag names it', async () => { + const moved = await movePin(pin, COMMIT, ['ios'], async platform => + bundle(platform === 'ios' ? IOS : ANDROID, COMMIT), + ); + assert.deepEqual(moved.pin.digests, { android: ANDROID, ios: IOS }); +}); + +test('movePin asks the registry nothing when the commit is abbreviated', async () => { + const resolve = mock.fn(); + const moved = await movePin(pin, 'aaaaa', [], resolve); + assert.deepEqual(moved, { kind: 'badCommit', commit: 'aaaaa' }); + assert.equal(resolve.mock.callCount(), 0); +}); + +test('fetchBundle asks the registry nothing when the checkout holds the pinned digest', async () => { + const { root, path } = await storeWithBundle(); + const requests = registry(bundle(ANDROID, COMMIT)); + const fetched = await fetchBundle(pin, 'android', root, false, requests); + assert.deepEqual(fetched, { kind: 'fetched', path }); + assert.equal(requests.resolveBundle.mock.callCount(), 0); +}); + +test("fetchBundle downloads into the digest's directory when the checkout lacks the bundle", async () => { + const root = await store(); + const requests = registry(bundle(ANDROID, COMMIT)); + const fetched = await fetchBundle(pin, 'android', root, false, requests); + assert.deepEqual(fetched, { + kind: 'fetched', + path: join(root, ANDROID.slice('sha256:'.length), FILE), + }); + assert.equal(requests.download.mock.calls[0].arguments[1], LAYER); +}); + +test('fetchBundle downloads nothing when the tag resolves to another digest', async () => { + const requests = registry(bundle(IOS, COMMIT)); + const fetched = await fetchBundle(pin, 'android', await store(), false, requests); + assert.equal(fetched.kind, 'movedTag'); + assert.equal(requests.download.mock.callCount(), 0); +}); + +test('fetchBundle reports a moved tag when asked to check, although the checkout holds the bundle', async () => { + const { root } = await storeWithBundle(); + const requests = registry(bundle(IOS, COMMIT)); + const fetched = await fetchBundle(pin, 'android', root, true, requests); + assert.equal(fetched.kind, 'movedTag'); +}); diff --git a/bindings/pin/test/swift_package.test.mjs b/bindings/pin/test/swift_package.test.mjs new file mode 100644 index 0000000000..4d48438555 --- /dev/null +++ b/bindings/pin/test/swift_package.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mock, test } from 'node:test'; +import { promisify } from 'node:util'; +import { MARKER, PACKAGE, placeSwiftPackage, placement } from '../lib/swift_package.mjs'; + +const DIGEST = `sha256:${'1'.repeat(64)}`; +const NEXT = `sha256:${'2'.repeat(64)}`; + +async function scratch() { + return mkdtemp(join(tmpdir(), 'bundle-pin-swift-')); +} + +async function archive(withManifest) { + const root = await scratch(); + await mkdir(join(root, PACKAGE, 'build'), { recursive: true }); + if (withManifest) { + await writeFile(join(root, PACKAGE, 'Package.swift'), '// swift-tools-version:5.9\n'); + } + const tarball = join(root, 'ZingoBindings.tar.gz'); + await promisify(execFile)('tar', ['-czf', tarball, '-C', root, PACKAGE]); + return tarball; +} + +test('placement holds the package when the marker names the digest and the manifest is present', () => { + assert.equal(placement(DIGEST, DIGEST, true), 'held'); + assert.equal(placement(NEXT, DIGEST, true), 'extract'); + assert.equal(placement(undefined, DIGEST, true), 'extract'); + assert.equal(placement(DIGEST, DIGEST, false), 'extract'); +}); + +test('placeSwiftPackage extracts the package to the fixed path and writes the marker', async () => { + const home = join(await scratch(), 'binding-layer'); + const placed = await placeSwiftPackage(await archive(true), DIGEST, home); + assert.deepEqual(placed, { kind: 'placed', path: join(home, PACKAGE) }); + assert.equal(await readFile(join(home, MARKER), 'utf8'), `${DIGEST}\n`); + assert.match(await readFile(join(home, PACKAGE, 'Package.swift'), 'utf8'), /swift-tools-version/); +}); + +test('placeSwiftPackage extracts nothing when the checkout holds the pinned digest', async () => { + const home = join(await scratch(), 'binding-layer'); + const tarball = await archive(true); + await placeSwiftPackage(tarball, DIGEST, home); + const extract = mock.fn(); + const placed = await placeSwiftPackage(tarball, DIGEST, home, extract); + assert.equal(placed.kind, 'placed'); + assert.equal(extract.mock.callCount(), 0); +}); + +test('placeSwiftPackage extracts again when the pin moves', async () => { + const home = join(await scratch(), 'binding-layer'); + const tarball = await archive(true); + await placeSwiftPackage(tarball, DIGEST, home); + const placed = await placeSwiftPackage(tarball, NEXT, home); + assert.equal(placed.kind, 'placed'); + assert.equal(await readFile(join(home, MARKER), 'utf8'), `${NEXT}\n`); +}); + +test('placeSwiftPackage reports a bundle that holds no package manifest', async () => { + const home = join(await scratch(), 'binding-layer'); + const placed = await placeSwiftPackage(await archive(false), DIGEST, home); + assert.deepEqual(placed, { kind: 'badPackage', platform: 'ios' }); +}); From dc3d3c2a8449476e9ca32e383020b08dc37aa74b Mon Sep 17 00:00:00 2001 From: zancas Date: Mon, 12 Oct 2026 16:19:10 -0700 Subject: [PATCH 2/5] fix(ci): let the npm publish authenticate by OIDC alone The registry-url setting of setup-node writes an npmrc entry that reads an auth token from an environment variable the job never sets. The empty entry can stand in the way of the OIDC exchange that trusted publishing performs, and the default registry is already npmjs.org, so the setting goes. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/binding-layer-publish.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/binding-layer-publish.yaml b/.github/workflows/binding-layer-publish.yaml index f899b0a0ca..7691ee6414 100644 --- a/.github/workflows/binding-layer-publish.yaml +++ b/.github/workflows/binding-layer-publish.yaml @@ -257,12 +257,12 @@ jobs: # Bundle Pin. It publishes with the Binding Layer, once per version: # the step publishes nothing when npm already holds the version the # package names. Trusted publishing needs an npm newer than the one - # Node 22 bundles. + # Node 22 bundles, and no registry-url here, since that setting + # writes an empty token entry that the OIDC exchange must not see. - name: Setup Node uses: actions/setup-node@v4 with: node-version: 22 - registry-url: https://registry.npmjs.org - name: Publish the Bundle Pin package when npm lacks its version working-directory: bindings/pin From 48bac9167024118bb46f75d54b3fb6235c58cb24 Mon Sep 17 00:00:00 2001 From: zancas Date: Mon, 12 Oct 2026 20:27:27 -0700 Subject: [PATCH 3/5] refactor(bindings): name the package zingolib-bundle-pin The npm scope zingolabs is held by an account that has published nothing, and whose holder is unknown. An unscoped name needs no organization, and trusted publishing serves it the same way, so the package is zingolib-bundle-pin. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/binding-layer-publish.yaml | 4 ++-- .github/workflows/bundle-pin.yaml | 8 ++++---- bindings/pin/README.md | 4 ++-- bindings/pin/package.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/binding-layer-publish.yaml b/.github/workflows/binding-layer-publish.yaml index 7691ee6414..d83df6e688 100644 --- a/.github/workflows/binding-layer-publish.yaml +++ b/.github/workflows/binding-layer-publish.yaml @@ -269,8 +269,8 @@ jobs: run: | npm install --global npm@latest version="$(node --print "require('./package.json').version")" - if npm view "@zingolabs/bundle-pin@${version}" version >/dev/null 2>&1; then - echo "npm holds @zingolabs/bundle-pin@${version}." + if npm view "zingolib-bundle-pin@${version}" version >/dev/null 2>&1; then + echo "npm holds zingolib-bundle-pin@${version}." else npm publish --access public --provenance fi diff --git a/.github/workflows/bundle-pin.yaml b/.github/workflows/bundle-pin.yaml index 9e996d20c5..4d7e8639db 100644 --- a/.github/workflows/bundle-pin.yaml +++ b/.github/workflows/bundle-pin.yaml @@ -1,4 +1,4 @@ -# bindings/pin is the npm package @zingolabs/bundle-pin, which consumers of +# bindings/pin is the npm package zingolib-bundle-pin, which consumers of # the Binding Layer install to hold their Bundle Pin and fetch the Prebuilt # Bundles. The package has no dependency, so the tests run on Node's own # runner with no install. The publish workflow publishes the package when @@ -57,8 +57,8 @@ jobs: exit 0 fi version="$(node --print "require('./package.json').version")" - if npm view "@zingolabs/bundle-pin@${version}" version >/dev/null 2>&1; then - echo "npm already holds @zingolabs/bundle-pin@${version}; raise the version in bindings/pin/package.json." + if npm view "zingolib-bundle-pin@${version}" version >/dev/null 2>&1; then + echo "npm already holds zingolib-bundle-pin@${version}; raise the version in bindings/pin/package.json." exit 1 fi - echo "@zingolabs/bundle-pin@${version} is unpublished." + echo "zingolib-bundle-pin@${version} is unpublished." diff --git a/bindings/pin/README.md b/bindings/pin/README.md index 91e534b4a3..1beb2adde8 100644 --- a/bindings/pin/README.md +++ b/bindings/pin/README.md @@ -1,4 +1,4 @@ -# @zingolabs/bundle-pin +# zingolib-bundle-pin The package that a consumer of the Binding Layer installs to hold its Bundle Pin and to fetch the pinned Prebuilt Bundles. zingolib publishes it to npm @@ -8,7 +8,7 @@ the scripts and the bundle layout they read change in one pull request. ## Install ```sh -yarn add --dev @zingolabs/bundle-pin +yarn add --dev zingolib-bundle-pin ``` The package depends on nothing beyond Node 20 and `tar`. diff --git a/bindings/pin/package.json b/bindings/pin/package.json index 11c218fec3..e407c43ec1 100644 --- a/bindings/pin/package.json +++ b/bindings/pin/package.json @@ -1,5 +1,5 @@ { - "name": "@zingolabs/bundle-pin", + "name": "zingolib-bundle-pin", "version": "0.1.0", "description": "Pins and fetches zingolib's published Prebuilt Bundles for a consumer of the Binding Layer.", "license": "MIT", From d5a8b08ef0939b6991cb97235590879a8126baa4 Mon Sep 17 00:00:00 2001 From: zancas Date: Tue, 13 Oct 2026 09:38:34 -0700 Subject: [PATCH 4/5] fix(bindings): publish the Bundle Pin package from dev and harden the scripts The npm publish of bindings/pin leaves the /publish job and becomes a publish job of the "Bundle Pin package" workflow that runs on a push to dev. The /publish job checked out the pull request's branch head, so the package shipped unmerged content, and its provenance named the default branch's head, a commit that did not hold the published files. A publish from dev names the merged commit, removes the race in which a later push to the pull request found its own version already on npm, and lets a failed publish retry on the next push instead of being refused with the recorded Binding Layer entry. Only the publish job holds id-token: write, and it installs a pinned npm rather than npm@latest. The scripts gain four fixes. The digest directory counts as held by its one visible entry, so a stray dotfile no longer forces a download whose rename then fails on the existing directory, and a directory that holds no bundle is replaced. parsePin returns a malformed-pin outcome for text that is not JSON and for null. fetch-binding-layer without --platform reports a message that names the platforms. The registry obtains one token per repository and passes it to the manifest and blob requests. Every failure message names pin-binding-layer, the published command, in place of the kitchen-sync alias yarn pin, and the header comments leave the lib and bin files. The tests cover each fixed behavior, and the README describes the publish. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/binding-layer-publish.yaml | 26 --------- .github/workflows/bundle-pin.yaml | 56 ++++++++++++++++++-- bindings/pin/README.md | 8 +++ bindings/pin/bin/fetch_binding_layer.mjs | 7 --- bindings/pin/bin/pin_binding_layer.mjs | 10 +--- bindings/pin/lib/bundle_messages.mjs | 12 ++--- bindings/pin/lib/bundle_pin.mjs | 11 ++-- bindings/pin/lib/fetch_binding_layer.mjs | 30 ++++++----- bindings/pin/lib/pin_file.mjs | 2 - bindings/pin/lib/registry.mjs | 30 +++++------ bindings/pin/test/bundle_pin.test.mjs | 48 +++++++++++++++-- 11 files changed, 151 insertions(+), 89 deletions(-) diff --git a/.github/workflows/binding-layer-publish.yaml b/.github/workflows/binding-layer-publish.yaml index d83df6e688..92fbafa6bb 100644 --- a/.github/workflows/binding-layer-publish.yaml +++ b/.github/workflows/binding-layer-publish.yaml @@ -29,12 +29,8 @@ on: # The reactions on the comment go through the issue-comment endpoint, # which the token's `issues` permission gates. -# The npm publish of bindings/pin signs its provenance with the job's OIDC -# token, which `id-token` grants; npm's trusted publishing for this -# repository and workflow takes the place of a stored npm token. permissions: contents: write - id-token: write issues: write packages: write pull-requests: write @@ -253,28 +249,6 @@ jobs: cargo run --quiet --manifest-path tools/workbench/Cargo.toml \ --bin binding-publish -- --commit "$COMMIT" --bundles "$BUNDLES" - # bindings/pin is the npm package every consumer installs to hold its - # Bundle Pin. It publishes with the Binding Layer, once per version: - # the step publishes nothing when npm already holds the version the - # package names. Trusted publishing needs an npm newer than the one - # Node 22 bundles, and no registry-url here, since that setting - # writes an empty token entry that the OIDC exchange must not see. - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Publish the Bundle Pin package when npm lacks its version - working-directory: bindings/pin - run: | - npm install --global npm@latest - version="$(node --print "require('./package.json').version")" - if npm view "zingolib-bundle-pin@${version}" version >/dev/null 2>&1; then - echo "npm holds zingolib-bundle-pin@${version}." - else - npm publish --access public --provenance - fi - # A failed build leaves the tool refusing in the step above, and a # failed push or record fails there too; either way the comment is # the only place the outcome can show, so one reaction reports it. diff --git a/.github/workflows/bundle-pin.yaml b/.github/workflows/bundle-pin.yaml index 4d7e8639db..65680491c0 100644 --- a/.github/workflows/bundle-pin.yaml +++ b/.github/workflows/bundle-pin.yaml @@ -1,9 +1,14 @@ # bindings/pin is the npm package zingolib-bundle-pin, which consumers of # the Binding Layer install to hold their Bundle Pin and fetch the Prebuilt # Bundles. The package has no dependency, so the tests run on Node's own -# runner with no install. The publish workflow publishes the package when -# its version is absent from npm, so a change to the package must raise the -# version, and the second job fails when it does not. +# runner with no install. +# +# The publish job runs on a push to dev and publishes the package when npm +# lacks the version it names, so the published content is always a merged +# commit and the provenance names that commit. A change to the package must +# therefore raise the version, and the check job fails a pull request that +# does not. A failed publish leaves npm without the version, and the next +# push to dev publishes it. # # The check has its own workflow with no `paths` filter, for the reason # no-duplicated-code.yaml states: a required check must report on every pull @@ -16,10 +21,17 @@ on: push: branches: [stable, dev] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + # Trusted publishing needs an npm newer than the one Node 22 bundles. + NPM_VERSION: 12.2.0 + jobs: bundle-pin: name: Bundle Pin package @@ -46,7 +58,7 @@ jobs: run: npm pack --dry-run # A pull request that changes the package must name a version npm - # does not hold, or the publish workflow would publish nothing. + # does not hold, or the publish job would publish nothing. - name: Check that a changed package raises its version if: ${{ github.event_name == 'pull_request' }} env: @@ -62,3 +74,39 @@ jobs: exit 1 fi echo "zingolib-bundle-pin@${version} is unpublished." + + # The OIDC token that `id-token` grants is what npm's trusted publishing + # for this repository and workflow accepts in place of a stored npm + # token, so only this job holds the grant. No registry-url goes to + # setup-node, since that setting writes an empty token entry that the + # OIDC exchange must not see. + publish: + name: Publish the Bundle Pin package + needs: bundle-pin + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: bindings/pin + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Publish the package when npm lacks its version + run: | + npm install --global "npm@${NPM_VERSION}" + version="$(node --print "require('./package.json').version")" + if npm view "zingolib-bundle-pin@${version}" version >/dev/null 2>&1; then + echo "npm holds zingolib-bundle-pin@${version}." + else + npm publish --access public --provenance + fi diff --git a/bindings/pin/README.md b/bindings/pin/README.md index 1beb2adde8..d84b13c369 100644 --- a/bindings/pin/README.md +++ b/bindings/pin/README.md @@ -58,3 +58,11 @@ package. The consumer's CI fetches each platform in its own job with ```sh node --test test/*.test.mjs ``` + +## Publish + +The workflow "Bundle Pin package" publishes this directory to npm on a push +to `dev` whenever npm lacks the version that `package.json` names, so every +published version is a merged commit and its provenance names that commit. A +pull request that changes `bin`, `lib`, or `package.json` must raise the +version, and the same workflow fails the pull request when it does not. diff --git a/bindings/pin/bin/fetch_binding_layer.mjs b/bindings/pin/bin/fetch_binding_layer.mjs index c1e5fa08c1..cabecc4e15 100755 --- a/bindings/pin/bin/fetch_binding_layer.mjs +++ b/bindings/pin/bin/fetch_binding_layer.mjs @@ -1,11 +1,4 @@ #!/usr/bin/env node -// Prints the path of the pinned Prebuilt Bundle for one platform, downloading -// it first when the checkout does not hold it. For iOS it prints the path of -// the ZingoBindings package. With --check-tag it also verifies that the -// registry's tag for the pinned commit resolves to the pinned digest. -// -// Usage, from the consumer's root: fetch-binding-layer --platform android [--check-tag] - import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { failureMessage } from '../lib/bundle_messages.mjs'; diff --git a/bindings/pin/bin/pin_binding_layer.mjs b/bindings/pin/bin/pin_binding_layer.mjs index 9bd25f1d75..1a849c04e8 100755 --- a/bindings/pin/bin/pin_binding_layer.mjs +++ b/bindings/pin/bin/pin_binding_layer.mjs @@ -1,16 +1,10 @@ #!/usr/bin/env node -// Moves the Bundle Pin to one zingolib commit. It asks the registry for the -// digest of each pinned platform's bundle and rewrites binding-layer.json, -// for every platform or for none. -// -// Usage, from the consumer's root: pin-binding-layer [--platform ] - import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { failureMessage } from '../lib/bundle_messages.mjs'; import { movePin, repository } from '../lib/bundle_pin.mjs'; import { readPin, writePin } from '../lib/pin_file.mjs'; -import { resolveBundle } from '../lib/registry.mjs'; +import { resolveTag } from '../lib/registry.mjs'; const { values, positionals } = parseArgs({ allowPositionals: true, @@ -29,7 +23,7 @@ const outcome = current, positionals[0] ?? '', values.platform, - (platform, commit) => resolveBundle(repository(platform), commit), + (platform, commit) => resolveTag(repository(platform), commit), ); if (outcome.kind !== 'moved') { process.stderr.write(`${failureMessage(outcome)}\n`); diff --git a/bindings/pin/lib/bundle_messages.mjs b/bindings/pin/lib/bundle_messages.mjs index e93155a1d1..8df09cb682 100644 --- a/bindings/pin/lib/bundle_messages.mjs +++ b/bindings/pin/lib/bundle_messages.mjs @@ -1,5 +1,3 @@ -// The message that a person reads for each failure of the Bundle Pin scripts. - import { PLATFORMS, reference, REGISTRY, repository } from './bundle_pin.mjs'; const REPORT = 'Report this to zingolib.'; @@ -10,6 +8,8 @@ export function failureMessage(failure) { return `"${failure.commit}" is not a full zingolib commit. Give all 40 hexadecimal characters.`; case 'noPlatform': return 'The Bundle Pin lists no platform. Add one with --platform android.'; + case 'unnamedPlatform': + return `Name the platform with --platform ${PLATFORMS.join(' or --platform ')}.`; case 'unknownPlatform': return `"${ failure.platform @@ -21,7 +21,7 @@ export function failureMessage(failure) { failure.platform, failure.commit, )}. ` + - 'Request the publish in zingolib, or pin a published commit with "yarn pin ".' + 'Request the publish in zingolib, or pin a published commit with "pin-binding-layer ".' ); case 'refused': return ( @@ -44,13 +44,13 @@ export function failureMessage(failure) { `and the Bundle Pin holds ${failure.pinned}. A published tag never moves. ${REPORT}` ); case 'malformedPin': - return `binding-layer.json holds no valid ${failure.field}. Write the file with "yarn pin ".`; + return `binding-layer.json holds no valid ${failure.field}. Write the file with "pin-binding-layer ".`; case 'unpinned': - return 'This checkout has no binding-layer.json. Create it with "yarn pin --platform android".'; + return 'This checkout has no binding-layer.json. Create it with "pin-binding-layer --platform android".'; case 'unpinnedPlatform': return ( `The Bundle Pin holds no ${failure.platform} digest. ` + - `Add it with "yarn pin --platform ${failure.platform}".` + `Add it with "pin-binding-layer --platform ${failure.platform}".` ); case 'badLayout': return `The ${failure.platform} bundle is not one named file in one layer. ${REPORT}`; diff --git a/bindings/pin/lib/bundle_pin.mjs b/bindings/pin/lib/bundle_pin.mjs index eda42a42f6..ad1bc7ee5c 100644 --- a/bindings/pin/lib/bundle_pin.mjs +++ b/bindings/pin/lib/bundle_pin.mjs @@ -1,6 +1,3 @@ -// The Bundle Pin: one zingolib commit, and one digest per platform. Every -// function here decides from its arguments alone. - export const REGISTRY = 'ghcr.io'; export const PLATFORMS = ['android', 'ios']; @@ -16,7 +13,13 @@ export function reference(platform, commit) { } export function parsePin(text) { - const { commit, digests } = JSON.parse(text); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return { kind: 'malformedPin', field: 'JSON' }; + } + const { commit, digests } = parsed ?? {}; if (!COMMIT.test(commit)) { return { kind: 'malformedPin', field: 'commit' }; } diff --git a/bindings/pin/lib/fetch_binding_layer.mjs b/bindings/pin/lib/fetch_binding_layer.mjs index 1091de5720..a516e46bfb 100644 --- a/bindings/pin/lib/fetch_binding_layer.mjs +++ b/bindings/pin/lib/fetch_binding_layer.mjs @@ -1,23 +1,20 @@ -// The pinned Prebuilt Bundle of one platform: fetchBundle downloads it into -// the store when the store lacks it, and pinnedBundle places the iOS package -// under /ios/binding-layer where the Xcode project references it. - -import { mkdir, readdir, rename } from 'node:fs/promises'; +import { mkdir, readdir, rename, rm } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { checkTag, PLATFORMS, repository } from './bundle_pin.mjs'; import { readPin } from './pin_file.mjs'; -import { download, resolveBundle } from './registry.mjs'; +import { access, download, resolveBundle } from './registry.mjs'; import { placeSwiftPackage } from './swift_package.mjs'; async function held(home) { - const files = await readdir(home).catch(failure => { + const entries = await readdir(home).catch(failure => { if (failure.code === 'ENOENT') { return []; } throw failure; }); - return files.length === 1 - ? { kind: 'held', path: join(home, files[0]) } + const visible = entries.filter(entry => !entry.startsWith('.')); + return visible.length === 1 + ? { kind: 'held', path: join(home, visible[0]) } : { kind: 'missing' }; } @@ -31,7 +28,11 @@ export async function fetchBundle(pin, platform, store, always, registry) { if (disk.kind === 'held' && !always) { return { kind: 'fetched', path: disk.path }; } - const found = await registry.resolveBundle(repository(platform), pin.commit); + const granted = await registry.access(repository(platform)); + const found = + granted.kind === 'granted' + ? await registry.resolveBundle(granted, pin.commit) + : granted; const tag = checkTag(platform, pin.commit, pinned, found); if (tag.kind !== 'agrees') { return tag; @@ -45,20 +46,25 @@ export async function fetchBundle(pin, platform, store, always, registry) { } const partial = `${home}.partial`; const file = basename(layer.file); + await rm(partial, { recursive: true, force: true }); await mkdir(partial, { recursive: true }); const blob = await registry.download( - repository(platform), + granted, layer.digest, join(partial, file), ); if (blob.kind !== 'downloaded') { return blob; } + await rm(home, { recursive: true, force: true }); await rename(partial, home); return { kind: 'fetched', path: join(home, file) }; } export async function pinnedBundle(root, values) { + if (values.platform === undefined) { + return { kind: 'unnamedPlatform' }; + } if (!PLATFORMS.includes(values.platform)) { return { kind: 'unknownPlatform', platform: values.platform }; } @@ -71,7 +77,7 @@ export async function pinnedBundle(root, values) { values.platform, values.store ?? join(root, values.platform, 'build', 'binding-layer'), values['check-tag'], - { resolveBundle, download }, + { access, resolveBundle, download }, ); if (fetched.kind !== 'fetched' || values.platform !== 'ios') { return fetched; diff --git a/bindings/pin/lib/pin_file.mjs b/bindings/pin/lib/pin_file.mjs index 97a24b08c2..cedc8877ac 100644 --- a/bindings/pin/lib/pin_file.mjs +++ b/bindings/pin/lib/pin_file.mjs @@ -1,5 +1,3 @@ -// The Bundle Pin's file: binding-layer.json. - import { readFile, rename, writeFile } from 'node:fs/promises'; import { parsePin, serializePin } from './bundle_pin.mjs'; diff --git a/bindings/pin/lib/registry.mjs b/bindings/pin/lib/registry.mjs index 560f52280b..bb6887a0d8 100644 --- a/bindings/pin/lib/registry.mjs +++ b/bindings/pin/lib/registry.mjs @@ -1,6 +1,3 @@ -// The registry requests of the Bundle Pin scripts: a token, a manifest, and a -// layer's content. - import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; import { rm } from 'node:fs/promises'; @@ -27,7 +24,7 @@ async function request(url, headers, tolerated) { return reply; } -async function access(repository) { +export async function access(repository) { const reply = await request( `https://${REGISTRY}/token?service=${REGISTRY}&scope=repository:${repository}:pull`, {}, @@ -37,16 +34,16 @@ async function access(repository) { return { kind: 'refused' }; } const { token } = await reply.json(); - return { kind: 'granted', headers: { Authorization: `Bearer ${token}` } }; + return { + kind: 'granted', + repository, + headers: { Authorization: `Bearer ${token}` }, + }; } -export async function resolveBundle(repository, tag) { - const granted = await access(repository); - if (granted.kind === 'refused') { - return granted; - } +export async function resolveBundle(granted, tag) { const reply = await request( - `https://${REGISTRY}/v2/${repository}/manifests/${tag}`, + `https://${REGISTRY}/v2/${granted.repository}/manifests/${tag}`, { ...granted.headers, Accept: MANIFEST }, NOT_FOUND, ); @@ -67,13 +64,14 @@ export async function resolveBundle(repository, tag) { }; } -export async function download(repository, digest, path) { +export async function resolveTag(repository, tag) { const granted = await access(repository); - if (granted.kind === 'refused') { - throw new Error(`${REGISTRY} refused access to ${repository}`); - } + return granted.kind === 'granted' ? resolveBundle(granted, tag) : granted; +} + +export async function download(granted, digest, path) { const reply = await request( - `https://${REGISTRY}/v2/${repository}/blobs/${digest}`, + `https://${REGISTRY}/v2/${granted.repository}/blobs/${digest}`, granted.headers, [], ); diff --git a/bindings/pin/test/bundle_pin.test.mjs b/bindings/pin/test/bundle_pin.test.mjs index 3effbb0c7a..6082f9f3f2 100644 --- a/bindings/pin/test/bundle_pin.test.mjs +++ b/bindings/pin/test/bundle_pin.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { mock, test } from 'node:test'; import { movePin, parsePin, serializePin } from '../lib/bundle_pin.mjs'; -import { fetchBundle } from '../lib/fetch_binding_layer.mjs'; +import { fetchBundle, pinnedBundle } from '../lib/fetch_binding_layer.mjs'; const COMMIT = 'a'.repeat(40); const NEXT = 'b'.repeat(40); @@ -27,8 +27,13 @@ function bundle(digest, revision) { function registry(found) { return { + access: mock.fn(async repository => ({ + kind: 'granted', + repository, + headers: {}, + })), resolveBundle: mock.fn(async () => found), - download: mock.fn(async (_repository, _digest, path) => { + download: mock.fn(async (_granted, _digest, path) => { await writeFile(path, 'bundle'); return { kind: 'downloaded', path }; }), @@ -64,6 +69,14 @@ test('parsePin names the commit when the commit is abbreviated', () => { assert.deepEqual(parsePin(text), { kind: 'malformedPin', field: 'commit' }); }); +test('parsePin names the JSON when the file does not parse', () => { + assert.deepEqual(parsePin('{"commit": }'), { kind: 'malformedPin', field: 'JSON' }); +}); + +test('parsePin names the commit when the file holds null', () => { + assert.deepEqual(parsePin('null'), { kind: 'malformedPin', field: 'commit' }); +}); + test('parsePin names the platform when its digest is malformed', () => { const text = JSON.stringify({ commit: COMMIT, digests: { android: 'sha256:1' } }); assert.deepEqual(parsePin(text), { kind: 'malformedPin', field: 'digests.android' }); @@ -142,6 +155,28 @@ test("fetchBundle downloads into the digest's directory when the checkout lacks path: join(root, ANDROID.slice('sha256:'.length), FILE), }); assert.equal(requests.download.mock.calls[0].arguments[1], LAYER); + assert.equal(requests.access.mock.callCount(), 1); +}); + +test('fetchBundle holds the bundle beside a dotfile in its directory', async () => { + const { root, path } = await storeWithBundle(); + await writeFile(join(dirname(path), '.DS_Store'), ''); + const requests = registry(bundle(ANDROID, COMMIT)); + const fetched = await fetchBundle(pin, 'android', root, false, requests); + assert.deepEqual(fetched, { kind: 'fetched', path }); + assert.equal(requests.download.mock.callCount(), 0); +}); + +test("fetchBundle replaces the digest's directory when it holds no bundle", async () => { + const root = await store(); + const home = join(root, ANDROID.slice('sha256:'.length)); + await mkdir(home); + await writeFile(join(home, 'stray.txt'), ''); + await writeFile(join(home, 'other.txt'), ''); + const requests = registry(bundle(ANDROID, COMMIT)); + const fetched = await fetchBundle(pin, 'android', root, false, requests); + assert.deepEqual(fetched, { kind: 'fetched', path: join(home, FILE) }); + assert.deepEqual(await readdir(home), [FILE]); }); test('fetchBundle downloads nothing when the tag resolves to another digest', async () => { @@ -157,3 +192,8 @@ test('fetchBundle reports a moved tag when asked to check, although the checkout const fetched = await fetchBundle(pin, 'android', root, true, requests); assert.equal(fetched.kind, 'movedTag'); }); + +test('pinnedBundle refuses before reading the pin when no platform is named', async () => { + const outcome = await pinnedBundle(join(tmpdir(), 'absent'), {}); + assert.deepEqual(outcome, { kind: 'unnamedPlatform' }); +}); From 0fd6850baf0e849ef360b4b82f3134a29e44149c Mon Sep 17 00:00:00 2001 From: zancas Date: Tue, 13 Oct 2026 10:52:09 -0700 Subject: [PATCH 5/5] refactor(bindings): share the disk helpers and mark the android bundle by name The ENOENT fallback that readPin, held, text, and exists each wrote now lives once in lib/disk.mjs as text and exists. The atomic replacement of a directory, which fetchBundle and placeSwiftPackage each spelled out, is replaceDirectory there: it fills a partial directory, discards the partial directory when the fill reports anything but success, and otherwise removes the old directory and renames the partial one into its place. Both bins take the default pin path from pinPath. The android bundle's directory gains a marker file, bundle, that names the bundle's file, as the iOS placement's marker names its digest. held reads the marker and checks that the named file exists, so a directory that holds one stray file no longer counts as the bundle. The marker is written after the download verifies, inside the partial directory, so its presence proves a complete bundle. A directory written before this commit holds no marker and is downloaded once more. The tests cover the marker, the replacement of an unmarked directory, and the removal of the partial directory after a corrupt download or a package without a manifest. Co-Authored-By: Claude Fable 5.1 --- bindings/pin/README.md | 7 ++-- bindings/pin/bin/fetch_binding_layer.mjs | 5 ++- bindings/pin/bin/pin_binding_layer.mjs | 10 ++--- bindings/pin/lib/disk.mjs | 38 +++++++++++++++++ bindings/pin/lib/fetch_binding_layer.mjs | 50 +++++++++++------------ bindings/pin/lib/pin_file.mjs | 17 ++++---- bindings/pin/lib/swift_package.mjs | 52 +++++++----------------- bindings/pin/test/bundle_pin.test.mjs | 23 ++++++++--- bindings/pin/test/swift_package.test.mjs | 5 ++- 9 files changed, 119 insertions(+), 88 deletions(-) create mode 100644 bindings/pin/lib/disk.mjs diff --git a/bindings/pin/README.md b/bindings/pin/README.md index d84b13c369..ef4113fa74 100644 --- a/bindings/pin/README.md +++ b/bindings/pin/README.md @@ -40,9 +40,10 @@ platforms with `--platform`. `fetch-binding-layer --platform [--check-tag]` prints the path of the pinned bundle, downloading it first when the checkout does not hold it. -Android bundles land under `android/build/binding-layer//`. The iOS -bundle is extracted to `ios/binding-layer/ZingoBindings`, beside a marker file -that names its digest, and the command prints that package path. With +Android bundles land under `android/build/binding-layer//`, beside a +marker file that names the bundle's file. The iOS bundle is extracted to +`ios/binding-layer/ZingoBindings`, beside a marker file that names its digest, +and the command prints that package path. With `--check-tag` the command also verifies that the registry's tag for the pinned commit still resolves to the pinned digest. diff --git a/bindings/pin/bin/fetch_binding_layer.mjs b/bindings/pin/bin/fetch_binding_layer.mjs index cabecc4e15..1f2f63b54f 100755 --- a/bindings/pin/bin/fetch_binding_layer.mjs +++ b/bindings/pin/bin/fetch_binding_layer.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { failureMessage } from '../lib/bundle_messages.mjs'; import { pinnedBundle } from '../lib/fetch_binding_layer.mjs'; +import { pinPath } from '../lib/pin_file.mjs'; const { values } = parseArgs({ options: { @@ -16,7 +17,7 @@ const { values } = parseArgs({ const root = resolve(values.root); const outcome = await pinnedBundle(root, { ...values, - pin: values.pin ?? join(root, 'binding-layer.json'), + pin: pinPath(root, values.pin), }); if (outcome.kind === 'fetched') { process.stdout.write(`${outcome.path}\n`); diff --git a/bindings/pin/bin/pin_binding_layer.mjs b/bindings/pin/bin/pin_binding_layer.mjs index 1a849c04e8..40d80da02b 100755 --- a/bindings/pin/bin/pin_binding_layer.mjs +++ b/bindings/pin/bin/pin_binding_layer.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { failureMessage } from '../lib/bundle_messages.mjs'; import { movePin, repository } from '../lib/bundle_pin.mjs'; -import { readPin, writePin } from '../lib/pin_file.mjs'; +import { pinPath, readPin, writePin } from '../lib/pin_file.mjs'; import { resolveTag } from '../lib/registry.mjs'; const { values, positionals } = parseArgs({ @@ -14,8 +14,8 @@ const { values, positionals } = parseArgs({ pin: { type: 'string' }, }, }); -const pinPath = values.pin ?? join(resolve(values.root), 'binding-layer.json'); -const current = await readPin(pinPath); +const path = pinPath(resolve(values.root), values.pin); +const current = await readPin(path); const outcome = current.kind === 'malformedPin' ? current @@ -29,7 +29,7 @@ if (outcome.kind !== 'moved') { process.stderr.write(`${failureMessage(outcome)}\n`); process.exitCode = 1; } else { - await writePin(pinPath, outcome.pin); + await writePin(path, outcome.pin); for (const [platform, digest] of Object.entries(outcome.pin.digests)) { process.stdout.write( `${platform} ${outcome.descriptors[platform]} ${digest}\n`, diff --git a/bindings/pin/lib/disk.mjs b/bindings/pin/lib/disk.mjs new file mode 100644 index 0000000000..9f250d80bc --- /dev/null +++ b/bindings/pin/lib/disk.mjs @@ -0,0 +1,38 @@ +import { mkdir, readFile, rename, rm, stat } from 'node:fs/promises'; + +function orAbsent(promise, absent) { + return promise.catch(failure => { + if (failure.code === 'ENOENT') { + return absent; + } + throw failure; + }); +} + +export function text(path) { + return orAbsent( + readFile(path, 'utf8').then(content => content.trim()), + undefined, + ); +} + +export function exists(path) { + return orAbsent( + stat(path).then(() => true), + false, + ); +} + +export async function replaceDirectory(home, fill) { + const partial = `${home}.partial`; + await rm(partial, { recursive: true, force: true }); + await mkdir(partial, { recursive: true }); + const outcome = await fill(partial); + if (outcome.kind !== 'filled') { + await rm(partial, { recursive: true, force: true }); + return outcome; + } + await rm(home, { recursive: true, force: true }); + await rename(partial, home); + return { kind: 'replaced' }; +} diff --git a/bindings/pin/lib/fetch_binding_layer.mjs b/bindings/pin/lib/fetch_binding_layer.mjs index a516e46bfb..13f9ec7ed5 100644 --- a/bindings/pin/lib/fetch_binding_layer.mjs +++ b/bindings/pin/lib/fetch_binding_layer.mjs @@ -1,21 +1,20 @@ -import { mkdir, readdir, rename, rm } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { checkTag, PLATFORMS, repository } from './bundle_pin.mjs'; +import { exists, replaceDirectory, text } from './disk.mjs'; import { readPin } from './pin_file.mjs'; import { access, download, resolveBundle } from './registry.mjs'; import { placeSwiftPackage } from './swift_package.mjs'; +export const MARKER = 'bundle'; + async function held(home) { - const entries = await readdir(home).catch(failure => { - if (failure.code === 'ENOENT') { - return []; - } - throw failure; - }); - const visible = entries.filter(entry => !entry.startsWith('.')); - return visible.length === 1 - ? { kind: 'held', path: join(home, visible[0]) } - : { kind: 'missing' }; + const file = await text(join(home, MARKER)); + if (file === undefined) { + return { kind: 'missing' }; + } + const path = join(home, file); + return (await exists(path)) ? { kind: 'held', path } : { kind: 'missing' }; } export async function fetchBundle(pin, platform, store, always, registry) { @@ -44,21 +43,22 @@ export async function fetchBundle(pin, platform, store, always, registry) { if (found.layers.length !== 1 || !layer.file) { return { kind: 'badLayout', platform }; } - const partial = `${home}.partial`; const file = basename(layer.file); - await rm(partial, { recursive: true, force: true }); - await mkdir(partial, { recursive: true }); - const blob = await registry.download( - granted, - layer.digest, - join(partial, file), - ); - if (blob.kind !== 'downloaded') { - return blob; - } - await rm(home, { recursive: true, force: true }); - await rename(partial, home); - return { kind: 'fetched', path: join(home, file) }; + const replaced = await replaceDirectory(home, async partial => { + const blob = await registry.download( + granted, + layer.digest, + join(partial, file), + ); + if (blob.kind !== 'downloaded') { + return blob; + } + await writeFile(join(partial, MARKER), `${file}\n`); + return { kind: 'filled' }; + }); + return replaced.kind === 'replaced' + ? { kind: 'fetched', path: join(home, file) } + : replaced; } export async function pinnedBundle(root, values) { diff --git a/bindings/pin/lib/pin_file.mjs b/bindings/pin/lib/pin_file.mjs index cedc8877ac..81ecb68e4e 100644 --- a/bindings/pin/lib/pin_file.mjs +++ b/bindings/pin/lib/pin_file.mjs @@ -1,14 +1,15 @@ -import { readFile, rename, writeFile } from 'node:fs/promises'; +import { rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { parsePin, serializePin } from './bundle_pin.mjs'; +import { text } from './disk.mjs'; + +export function pinPath(root, pin) { + return pin ?? join(root, 'binding-layer.json'); +} export async function readPin(path) { - const text = await readFile(path, 'utf8').catch(failure => { - if (failure.code === 'ENOENT') { - return undefined; - } - throw failure; - }); - return text === undefined ? { kind: 'unpinned' } : parsePin(text); + const content = await text(path); + return content === undefined ? { kind: 'unpinned' } : parsePin(content); } export async function writePin(path, pin) { diff --git a/bindings/pin/lib/swift_package.mjs b/bindings/pin/lib/swift_package.mjs index ff4f9000fb..f66bd89b7f 100644 --- a/bindings/pin/lib/swift_package.mjs +++ b/bindings/pin/lib/swift_package.mjs @@ -1,7 +1,8 @@ import { execFile } from 'node:child_process'; -import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { exists, replaceDirectory, text } from './disk.mjs'; export const PACKAGE = 'ZingoBindings'; export const MARKER = 'digest'; @@ -11,48 +12,25 @@ export function placement(marker, digest, manifestPresent) { return marker === digest && manifestPresent ? 'held' : 'extract'; } -async function text(path) { - return readFile(path, 'utf8') - .then(content => content.trim()) - .catch(failure => { - if (failure.code === 'ENOENT') { - return undefined; - } - throw failure; - }); -} - -async function exists(path) { - return stat(path) - .then(() => true) - .catch(failure => { - if (failure.code === 'ENOENT') { - return false; - } - throw failure; - }); -} - export async function untar(archive, into) { await promisify(execFile)('tar', ['-xzf', archive, '-C', into]); } export async function placeSwiftPackage(archive, digest, home, extract = untar) { - const manifest = join(home, PACKAGE, MANIFEST); const marker = await text(join(home, MARKER)); - if (placement(marker, digest, await exists(manifest)) === 'held') { + const manifestPresent = await exists(join(home, PACKAGE, MANIFEST)); + if (placement(marker, digest, manifestPresent) === 'held') { return { kind: 'placed', path: join(home, PACKAGE) }; } - const partial = `${home}.partial`; - await rm(partial, { recursive: true, force: true }); - await mkdir(partial, { recursive: true }); - await extract(archive, partial); - if (!(await exists(join(partial, PACKAGE, MANIFEST)))) { - await rm(partial, { recursive: true, force: true }); - return { kind: 'badPackage', platform: 'ios' }; - } - await writeFile(join(partial, MARKER), `${digest}\n`); - await rm(home, { recursive: true, force: true }); - await rename(partial, home); - return { kind: 'placed', path: join(home, PACKAGE) }; + const replaced = await replaceDirectory(home, async partial => { + await extract(archive, partial); + if (!(await exists(join(partial, PACKAGE, MANIFEST)))) { + return { kind: 'badPackage', platform: 'ios' }; + } + await writeFile(join(partial, MARKER), `${digest}\n`); + return { kind: 'filled' }; + }); + return replaced.kind === 'replaced' + ? { kind: 'placed', path: join(home, PACKAGE) } + : replaced; } diff --git a/bindings/pin/test/bundle_pin.test.mjs b/bindings/pin/test/bundle_pin.test.mjs index 6082f9f3f2..ea66de16ce 100644 --- a/bindings/pin/test/bundle_pin.test.mjs +++ b/bindings/pin/test/bundle_pin.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { mock, test } from 'node:test'; import { movePin, parsePin, serializePin } from '../lib/bundle_pin.mjs'; -import { fetchBundle, pinnedBundle } from '../lib/fetch_binding_layer.mjs'; +import { fetchBundle, MARKER, pinnedBundle } from '../lib/fetch_binding_layer.mjs'; const COMMIT = 'a'.repeat(40); const NEXT = 'b'.repeat(40); @@ -49,6 +49,7 @@ async function storeWithBundle() { const home = join(root, ANDROID.slice('sha256:'.length)); await mkdir(home); await writeFile(join(home, FILE), 'bundle'); + await writeFile(join(home, MARKER), `${FILE}\n`); return { root, path: join(home, FILE) }; } @@ -156,6 +157,7 @@ test("fetchBundle downloads into the digest's directory when the checkout lacks }); assert.equal(requests.download.mock.calls[0].arguments[1], LAYER); assert.equal(requests.access.mock.callCount(), 1); + assert.equal(await readFile(join(root, ANDROID.slice('sha256:'.length), MARKER), 'utf8'), `${FILE}\n`); }); test('fetchBundle holds the bundle beside a dotfile in its directory', async () => { @@ -167,16 +169,25 @@ test('fetchBundle holds the bundle beside a dotfile in its directory', async () assert.equal(requests.download.mock.callCount(), 0); }); -test("fetchBundle replaces the digest's directory when it holds no bundle", async () => { +test("fetchBundle replaces the digest's directory when no marker names its bundle", async () => { const root = await store(); const home = join(root, ANDROID.slice('sha256:'.length)); await mkdir(home); - await writeFile(join(home, 'stray.txt'), ''); - await writeFile(join(home, 'other.txt'), ''); + await writeFile(join(home, FILE), 'stale'); const requests = registry(bundle(ANDROID, COMMIT)); const fetched = await fetchBundle(pin, 'android', root, false, requests); assert.deepEqual(fetched, { kind: 'fetched', path: join(home, FILE) }); - assert.deepEqual(await readdir(home), [FILE]); + assert.deepEqual((await readdir(home)).sort(), [FILE, MARKER].sort()); + assert.equal(await readFile(join(home, FILE), 'utf8'), 'bundle'); +}); + +test('fetchBundle leaves no directory when the download is corrupt', async () => { + const root = await store(); + const requests = registry(bundle(ANDROID, COMMIT)); + requests.download = mock.fn(async () => ({ kind: 'corrupt', expected: LAYER, found: IOS })); + const fetched = await fetchBundle(pin, 'android', root, false, requests); + assert.equal(fetched.kind, 'corrupt'); + assert.deepEqual(await readdir(root), []); }); test('fetchBundle downloads nothing when the tag resolves to another digest', async () => { diff --git a/bindings/pin/test/swift_package.test.mjs b/bindings/pin/test/swift_package.test.mjs index 4d48438555..5d9e8c364e 100644 --- a/bindings/pin/test/swift_package.test.mjs +++ b/bindings/pin/test/swift_package.test.mjs @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { mock, test } from 'node:test'; import { promisify } from 'node:util'; import { MARKER, PACKAGE, placeSwiftPackage, placement } from '../lib/swift_package.mjs'; @@ -63,4 +63,5 @@ test('placeSwiftPackage reports a bundle that holds no package manifest', async const home = join(await scratch(), 'binding-layer'); const placed = await placeSwiftPackage(await archive(false), DIGEST, home); assert.deepEqual(placed, { kind: 'badPackage', platform: 'ios' }); + assert.deepEqual(await readdir(dirname(home)), []); });