-
Notifications
You must be signed in to change notification settings - Fork 0
Automate transcriber publication and fix the blocked ONNX runtime #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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'; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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`; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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]; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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); | ||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defensive programming: Adding a check to ensure
Suggested change
|
||||||||||||||
| 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]); | ||||||||||||||
|
YurMil marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The workflow downloads 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'); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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}); | ||||||||||||||
|
|
@@ -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); | ||||||||||||||
|
|
@@ -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, | ||||||||||||||
|
|
@@ -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}); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| 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" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
readFlagfunction currently returns the next argument inprocess.argvwithout 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--sha256as the archive path).Adding a check to ensure the value does not start with a hyphen (
-) makes the argument parsing more robust.