Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .github/workflows/bundle-pin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 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 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
# request.
name: Bundle Pin package

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
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
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 job 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 "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 "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
69 changes: 69 additions & 0 deletions bindings/pin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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
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 zingolib-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 <dir>` names another root,
and `--pin <file>` another pin file.

`pin-binding-layer <commit> [--platform <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 <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/<digest>/`, 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.

## 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
```

## 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.
27 changes: 27 additions & 0 deletions bindings/pin/bin/fetch_binding_layer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
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: {
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: pinPath(root, values.pin),
});
if (outcome.kind === 'fetched') {
process.stdout.write(`${outcome.path}\n`);
} else {
process.stderr.write(`${failureMessage(outcome)}\n`);
process.exitCode = 1;
}
38 changes: 38 additions & 0 deletions bindings/pin/bin/pin_binding_layer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env node
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 { pinPath, readPin, writePin } from '../lib/pin_file.mjs';
import { resolveTag } 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 path = pinPath(resolve(values.root), values.pin);
const current = await readPin(path);
const outcome =
current.kind === 'malformedPin'
? current
: await movePin(
current,
positionals[0] ?? '',
values.platform,
(platform, commit) => resolveTag(repository(platform), commit),
);
if (outcome.kind !== 'moved') {
process.stderr.write(`${failureMessage(outcome)}\n`);
process.exitCode = 1;
} else {
await writePin(path, outcome.pin);
for (const [platform, digest] of Object.entries(outcome.pin.digests)) {
process.stdout.write(
`${platform} ${outcome.descriptors[platform]} ${digest}\n`,
);
}
}
62 changes: 62 additions & 0 deletions bindings/pin/lib/bundle_messages.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 'unnamedPlatform':
return `Name the platform with --platform ${PLATFORMS.join(' or --platform ')}.`;
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 "pin-binding-layer <commit>".'
);
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 "pin-binding-layer <commit>".`;
case 'unpinned':
return 'This checkout has no binding-layer.json. Create it with "pin-binding-layer <commit> --platform android".';
case 'unpinnedPlatform':
return (
`The Bundle Pin holds no ${failure.platform} digest. ` +
`Add it with "pin-binding-layer <commit> --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}`;
}
}
Loading
Loading