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
165 changes: 165 additions & 0 deletions .github/workflows/sync-whisper-transcriber.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
name: Sync Whisper Transcriber

# Republishes static/utility-apps/whisper-transcriber/ from a release of
# YurMil/ws-speech-text and opens a pull request with the result.
#
# Triggered by that repository's Release workflow (repository_dispatch), or
# manually from the Actions tab when a release needs to be re-pulled.
#
# The artifact is only accepted if its SHA-256 matches the digest published
# with the release, and the sync script re-audits every file before publishing.
# This workflow never builds the transcriber and never downloads model weights.

on:
repository_dispatch:
types: [whisper-transcriber-release]
workflow_dispatch:
inputs:
tag:
description: Release tag in YurMil/ws-speech-text (e.g. v0.1.0)
required: true
sha256:
description: Expected SHA-256 of the archive (blank = take it from the release)
required: false

concurrency:
group: sync-whisper-transcriber
cancel-in-progress: false

permissions:
contents: write
pull-requests: write

jobs:
sync:
runs-on: ubuntu-latest
env:
SOURCE_REPOSITORY: YurMil/ws-speech-text
TAG: ${{ github.event.client_payload.tag || inputs.tag }}
EXPECTED_SHA256: ${{ github.event.client_payload.sha256 || inputs.sha256 }}
ARCHIVE: ${{ github.event.client_payload.archive }}
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm

- name: Download release archive
env:
# Reading a public release needs no extra scope; a PAT is only
# required if the source repository is private.
GH_TOKEN: ${{ secrets.SOURCE_RELEASE_TOKEN || github.token }}
run: |
set -euo pipefail
mkdir -p .artifact
archive="${ARCHIVE:-whisper-transcriber-${TAG}.zip}"
gh release download "$TAG" --repo "$SOURCE_REPOSITORY" \
--pattern "$archive" --pattern "$archive.sha256" --dir .artifact
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"

- name: Resolve expected checksum
run: |
set -euo pipefail
if [ -z "${EXPECTED_SHA256}" ]; then
# Fall back to the digest published alongside the archive. The
# dispatch payload is preferred: it is produced by the build itself.
EXPECTED_SHA256="$(cut -d' ' -f1 < ".artifact/${ARCHIVE}.sha256")"
echo "Using checksum from the release: ${EXPECTED_SHA256}"
fi
echo "EXPECTED_SHA256=${EXPECTED_SHA256}" >> "$GITHUB_ENV"

- name: Publish artifact
run: |
node scripts/sync-whisper-transcriber.mjs \
--archive ".artifact/${ARCHIVE}" \
--sha256 "${EXPECTED_SHA256}"

- name: Detect changes
id: diff
run: |
set -euo pipefail
rm -rf .artifact
# --porcelain rather than `git diff`, so a release that only adds a
# newly hashed asset still counts as a change.
if [ -z "$(git status --porcelain -- static/utility-apps/whisper-transcriber)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Published artifact is identical to the committed one; nothing to do."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Install dependencies
if: steps.diff.outputs.changed == 'true'
run: pnpm install --frozen-lockfile

- name: Typecheck
if: steps.diff.outputs.changed == 'true'
run: pnpm typecheck

- name: Lint
if: steps.diff.outputs.changed == 'true'
run: pnpm lint

- name: Build
if: steps.diff.outputs.changed == 'true'
run: pnpm build
env:
DOCUSAURUS_ONBROKENLINKS: throw
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
AUTH_REDIRECT_URL: ${{ secrets.AUTH_REDIRECT_URL }}

- name: Open pull request
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
branch="chore/whisper-transcriber-${TAG}"
build_id="$(node -p "require('./static/utility-apps/whisper-transcriber/manifest.json').buildId")"
version="$(node -p "require('./static/utility-apps/whisper-transcriber/manifest.json').version")"

{
printf 'Publish Whisper Transcriber %s\n\n' "$TAG"
printf 'Artifact from the %s release %s.\n\n' "$SOURCE_REPOSITORY" "$TAG"
printf -- '- version: %s\n' "$version"
printf -- '- build id: %s\n' "$build_id"
printf -- '- archive sha256: %s\n\n' "$EXPECTED_SHA256"
printf 'Verified against the published checksum and re-audited by\n'
printf 'scripts/sync-whisper-transcriber.mjs before publishing.\n'
} > commit-msg.md

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add static/utility-apps/whisper-transcriber
git commit -F commit-msg.md
rm -f commit-msg.md
git push --force-with-lease origin "$branch"

{
printf 'Republishes the embedded Whisper Transcriber from [%s@%s](https://github.com/%s/releases/tag/%s).\n\n' \
"$SOURCE_REPOSITORY" "$TAG" "$SOURCE_REPOSITORY" "$TAG"
printf '| | |\n|---|---|\n'
printf '| Version | `%s` |\n' "$version"
printf '| Build id | `%s` |\n' "$build_id"
printf '| Archive SHA-256 | `%s` |\n\n' "$EXPECTED_SHA256"
printf 'The archive checksum was verified before extraction, and every file was\n'
printf 're-audited (no symlinks, traversal, source maps or dev references; the entry\n'
printf 'document may only reference packaged relative assets). Typecheck, lint and a\n'
printf 'six-locale production build passed on this branch.\n'
} > pr-body.md

existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')"
if [ -n "$existing" ]; then
gh pr edit "$existing" --body-file pr-body.md
echo "Updated existing pull request #${existing}"
else
gh pr create --base main --head "$branch" \
--title "Publish Whisper Transcriber ${TAG}" --body-file pr-body.md
fi
rm -f pr-body.md
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,29 @@ AUTH_REDIRECT_URL=http://localhost:3000/auth/callback
5. Add the catalog name/description to all six `src/i18n/locales/*.ts` dictionaries
6. Place standalone build assets in `static/utility-apps/<slug>/` if needed

### Embedded apps built in their own repository

Utilities too heavy to live in the Docusaurus bundle are built elsewhere and
published here as static artifacts. The Whisper Transcriber is the reference
implementation of that pipeline:

1. Tagging a release in [`YurMil/ws-speech-text`](https://github.com/YurMil/ws-speech-text)
builds the bundle, audits it, and attaches a checksummed archive to a GitHub
release.
2. That workflow sends a `whisper-transcriber-release` `repository_dispatch` here
(secret `SITE_DISPATCH_TOKEN`, set in the source repository).
3. `.github/workflows/sync-whisper-transcriber.yml` downloads the archive,
refuses it unless the SHA-256 matches, republishes
`static/utility-apps/whisper-transcriber/` via
`scripts/sync-whisper-transcriber.mjs`, runs typecheck/lint/build, and opens
a pull request.

Nothing is deployed without review, and this repository never builds the app or
downloads model weights. The same script publishes from a local checkout during
development (`npm run sync:whisper-transcriber`).

### Device capabilities

Device capabilities (camera, microphone) are delegated **per utility** through the
`iframeAllow` field of `UtilityPageConfig`, paired with a path-scoped
`Permissions-Policy` rule in `vercel.json`. Never widen the global policy: the
Expand Down
87 changes: 72 additions & 15 deletions scripts/sync-whisper-transcriber.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@
//
// The transcriber lives in its own repository (YurMil/ws-speech-text) because
// it carries Transformers.js and ONNX Runtime Web, which must never enter the
// Docusaurus bundle. This script builds that source tree, audits the produced
// bundle, and republishes it atomically.
// Docusaurus bundle. This script takes a bundle produced there, audits it, and
// republishes it atomically.
//
// Usage:
// npm run sync:whisper-transcriber
// build the sibling checkout and publish it
// WHISPER_TRANSCRIBER_DIR=/path/to/ws-speech-text npm run sync:whisper-transcriber
// npm run sync:whisper-transcriber -- --skip-build (reuse an existing dist/)
// build a checkout somewhere else
// npm run sync:whisper-transcriber -- --skip-build
// reuse an existing dist/
// npm run sync:whisper-transcriber -- --archive dist.zip --sha256 <hex>
// publish a release archive, refusing it unless the checksum matches.
// This is the path CI uses; see .github/workflows/sync-whisper-transcriber.yml.

import {createHash} from 'node:crypto';
import {spawnSync} from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

Expand All @@ -25,7 +32,6 @@ const DEFAULT_SOURCE_DIR = path.resolve(
'ws-speech-text',
);
const SOURCE_DIR = path.resolve(process.env.WHISPER_TRANSCRIBER_DIR || DEFAULT_SOURCE_DIR);
const SOURCE_DIST_DIR = path.join(SOURCE_DIR, 'dist');
const TARGET_DIR = path.join(SITE_ROOT, 'static', 'utility-apps', 'whisper-transcriber');
const STAGING_DIR = `${TARGET_DIR}.staging`;

Expand All @@ -38,7 +44,16 @@ const FORBIDDEN_PATTERNS = [
/sourceMappingURL/i,
];

function readFlag(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
}
Comment on lines +47 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The readFlag function currently returns the next argument in process.argv without verifying if it is another flag. If a flag is passed without a value (or if flags are passed in a different order), this can lead to unexpected behavior (e.g., treating --sha256 as the archive path).

Adding a check to ensure the value does not start with a hyphen (-) makes the argument parsing more robust.

Suggested change
function readFlag(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
}
function readFlag(name) {
const index = process.argv.indexOf(name);
if (index === -1) return undefined;
const value = process.argv[index + 1];
return value && !value.startsWith('-') ? value : undefined;
}


const skipBuild = process.argv.includes('--skip-build');
const archivePath = readFlag('--archive');
const expectedSha256 = readFlag('--sha256');

let scratchDir = null;

function fail(message) {
throw new Error(message);
Expand Down Expand Up @@ -134,16 +149,47 @@ function readBuildInfo(distDir) {
const infoPath = path.join(distDir, 'build-info.json');
assertExists(infoPath, 'ws-speech-text dist/build-info.json');
const info = JSON.parse(fs.readFileSync(infoPath, 'utf8'));
if (!info.buildId || !info.version) {
fail('build-info.json is missing version or buildId.');
if (!info.buildId || !info.version || !info.buildTime) {
fail('build-info.json is missing version, buildId or buildTime.');
}
Comment on lines +152 to 154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensive programming: JSON.parse can theoretically return null or non-object values if the JSON file is malformed or empty. Accessing properties like buildId on null will throw a TypeError and crash the script with an unhelpful error message.

Adding a check to ensure info is a non-null object before accessing its properties provides better safety.

Suggested change
if (!info.buildId || !info.version || !info.buildTime) {
fail('build-info.json is missing version, buildId or buildTime.');
}
if (!info || typeof info !== 'object' || !info.buildId || !info.version || !info.buildTime) {
fail('build-info.json is missing version, buildId or buildTime.');
}

if (info.buildId === 'unversioned') {
fail('Refusing to publish an artifact built outside a git checkout.');
}
return info;
}

function main() {
/**
* Unpacks a release archive after verifying its checksum. The digest is checked
* before anything is extracted, so a tampered or truncated download never
* reaches the filesystem walk.
*/
function extractArchive(archive) {
assertExists(archive, 'release archive');

const actual = sha256(archive);
if (!expectedSha256) {
fail('--archive requires --sha256 <hex>; refusing to publish an unverified archive.');
}
if (actual !== expectedSha256.trim().toLowerCase()) {
fail(`Archive checksum mismatch.\n expected ${expectedSha256}\n actual ${actual}`);
}
console.log(`[whisper-transcriber] Archive checksum verified: ${actual}`);

scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'whisper-transcriber-'));
// bsdtar reads zip archives and is present on Windows, macOS and the runners.
run('tar', ['-xf', path.resolve(archive), '-C', scratchDir]);
Comment thread
YurMil marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Extract release ZIPs with a ZIP-capable tool

The workflow downloads whisper-transcriber-${TAG}.zip and passes it to this --archive path, but on ubuntu-latest tar is GNU tar rather than bsdtar; I checked the CLI behavior (tar --version reports tar (GNU tar) 1.35) and tar -xf on a ZIP exits with This does not look like a tar archive. As a result, repository_dispatch/manual syncs for the advertised ZIP release will fail here immediately after checksum verification, before the artifact can be republished or a PR opened.

Useful? React with 👍 / 👎.


// Accept both a flat archive and one that wraps the files in dist/.
const nested = path.join(scratchDir, 'dist');
return fs.existsSync(path.join(nested, 'index.html')) ? nested : scratchDir;
}

/** Produces the directory whose contents should be published. */
function resolveDistDir() {
if (archivePath) {
return extractArchive(archivePath);
}

assertExists(SOURCE_DIR, 'ws-speech-text source directory');
assertExists(path.join(SOURCE_DIR, 'package.json'), 'ws-speech-text package.json');

Expand All @@ -155,15 +201,20 @@ function main() {
run('pnpm', ['build'], SOURCE_DIR);
}

assertExists(SOURCE_DIST_DIR, 'ws-speech-text dist directory');
assertExists(path.join(SOURCE_DIST_DIR, 'index.html'), 'ws-speech-text dist/index.html');
return path.join(SOURCE_DIR, 'dist');
}

function main() {
const distDir = resolveDistDir();
assertExists(distDir, 'transcriber dist directory');
assertExists(path.join(distDir, 'index.html'), 'transcriber dist/index.html');

const files = collectFiles(SOURCE_DIST_DIR);
const files = collectFiles(distDir);
const assets = files.filter((file) => file !== 'index.html');
const entryHtml = fs.readFileSync(path.join(SOURCE_DIST_DIR, 'index.html'), 'utf8');
const entryHtml = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8');
auditEntryHtml(entryHtml, assets);

const {version, buildId} = readBuildInfo(SOURCE_DIST_DIR);
const {version, buildId, buildTime} = readBuildInfo(distDir);

// Stage first, then swap, so a failed sync never leaves a half-published app.
fs.rmSync(STAGING_DIR, {recursive: true, force: true});
Expand All @@ -172,7 +223,7 @@ function main() {
const checksums = {};
let totalBytes = 0;
for (const relative of assets) {
const source = path.join(SOURCE_DIST_DIR, relative);
const source = path.join(distDir, relative);
const destination = path.join(STAGING_DIR, relative);
fs.mkdirSync(path.dirname(destination), {recursive: true});
fs.copyFileSync(source, destination);
Expand All @@ -194,7 +245,9 @@ function main() {
name: 'whisper-transcriber',
version,
buildId,
buildTime: new Date().toISOString(),
// Taken from the source build, not from now(), so republishing the same
// artifact is a no-op and CI can tell whether anything actually changed.
buildTime,
entry: 'app.html',
assets,
checksums,
Expand All @@ -218,5 +271,9 @@ try {
} catch (error) {
fs.rmSync(STAGING_DIR, {recursive: true, force: true});
console.error(`[whisper-transcriber] ${error.message}`);
process.exit(1);
process.exitCode = 1;
} finally {
if (scratchDir) {
fs.rmSync(scratchDir, {recursive: true, force: true});
}
}
2 changes: 1 addition & 1 deletion static/utility-apps/whisper-transcriber/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
content="Private browser speech-to-text. Audio stays on your device."
/>
<title>Whisper Transcriber</title>
<script type="module" crossorigin src="./assets/index-B5C8PNiW.js"></script>
<script type="module" crossorigin src="./assets/index-DA9_-avd.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BWGSeFCx.css">
</head>
<body>
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion static/utility-apps/whisper-transcriber/build-info.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"version": "0.1.0-prototype",
"buildId": "0d8c0a8b93e2"
"buildId": "c53387ee8485",
"buildTime": "2026-07-21T14:03:58.052Z"
}
2 changes: 1 addition & 1 deletion static/utility-apps/whisper-transcriber/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
content="Private browser speech-to-text. Audio stays on your device."
/>
<title>Whisper Transcriber</title>
<script type="module" crossorigin src="./assets/index-B5C8PNiW.js"></script>
<script type="module" crossorigin src="./assets/index-DA9_-avd.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BWGSeFCx.css">
</head>
<body>
Expand Down
Loading
Loading