diff --git a/.changeset/electron-passkeys-canary-binaries.md b/.changeset/electron-passkeys-canary-binaries.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/electron-passkeys-canary-binaries.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/electron-passkeys.yml b/.github/workflows/electron-passkeys.yml index 57c1a24efc0..08a8788fa29 100644 --- a/.github/workflows/electron-passkeys.yml +++ b/.github/workflows/electron-passkeys.yml @@ -2,6 +2,15 @@ name: Electron Passkeys Native Build on: workflow_dispatch: + push: + branches: + - main + paths: + - 'packages/electron-passkeys/src/**' + - 'packages/electron-passkeys/Cargo.toml' + - 'packages/electron-passkeys/Cargo.lock' + - 'packages/electron-passkeys/build.rs' + - '.github/workflows/electron-passkeys.yml' concurrency: group: electron-passkeys-${{ github.head_ref || github.ref }} @@ -101,10 +110,17 @@ jobs: run: node scripts/check-electron-passkeys-binaries.mjs packages/electron-passkeys/npm working-directory: . + - name: Record the source hash the binaries were built from + run: node scripts/electron-passkeys-source-hash.mjs write + working-directory: . + - name: Upload assembled platform binaries uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: electron-passkeys-npm - path: packages/electron-passkeys/npm/**/*.node + path: | + packages/electron-passkeys/npm/**/*.node + packages/electron-passkeys/npm/.source-hash + include-hidden-files: true if-no-files-found: error retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7037e0773dd..56bbbace9e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,8 @@ jobs: with: cache-enabled: false # OIDC publish job; do not restore poisonable cache state. turbo-enabled: false # Release uses --force, so turbo cache is not needed - turbo-team: "" - turbo-token: "" + turbo-team: '' + turbo-token: '' - name: Upgrade npm for trusted publishing run: npx npm@11 install -g npm@11 @@ -90,18 +90,22 @@ jobs: if: ${{ steps.detect-native.outputs.should-build == 'true' }} run: node scripts/check-electron-passkeys-binaries.mjs packages/electron-passkeys/npm + - name: Verify electron-passkeys binaries match the current native sources + if: ${{ steps.detect-native.outputs.should-build == 'true' }} + run: node scripts/electron-passkeys-source-hash.mjs verify + - name: Create Release PR id: changesets uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1 with: - commit: "ci(repo): Version packages" - title: "ci(repo): Version packages" + commit: 'ci(repo): Version packages' + title: 'ci(repo): Version packages' publish: pnpm release # Workaround for https://github.com/changesets/changesets/issues/421 version: pnpm version-packages env: GITHUB_TOKEN: ${{ secrets.CLERK_COOKIE_PAT }} - HUSKY: "0" + HUSKY: '0' NPM_CONFIG_PROVENANCE: true - name: Trigger workflows on related repos @@ -269,8 +273,10 @@ jobs: name: Canary release if: ${{ github.event_name == 'push' && github.repository == 'clerk/javascript' }} runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }} - timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }} + # Longer than the other jobs so it can wait for an in-flight electron-passkeys native build on the same commit. + timeout-minutes: 30 permissions: + actions: read contents: read id-token: write steps: @@ -288,8 +294,8 @@ jobs: with: cache-enabled: false # OIDC publish job; do not restore poisonable cache state. turbo-enabled: false # Remote cache disabled in OIDC publish path. - turbo-team: "" - turbo-token: "" + turbo-team: '' + turbo-token: '' - name: Upgrade npm for trusted publishing run: npx npm@11 install -g npm@11 @@ -302,6 +308,54 @@ jobs: if: steps.version-packages.outputs.success == '1' run: pnpm turbo build $TURBO_ARGS + - name: Wait for an in-flight electron-passkeys native build on this commit + if: steps.version-packages.outputs.success == '1' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const deadline = Date.now() + 20 * 60 * 1000; + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + while (true) { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'electron-passkeys.yml', + head_sha: context.sha, + per_page: 10, + }); + const pending = data.workflow_runs.filter(run => run.status !== 'completed'); + if (pending.length === 0) { + return; + } + if (Date.now() > deadline) { + core.setFailed(`electron-passkeys native build ${pending[0].html_url} is still running; re-run this job once it finishes`); + return; + } + core.info(`Waiting for ${pending[0].html_url} (${pending[0].status})...`); + await sleep(30_000); + } + + - name: Download electron-passkeys native binaries + if: steps.version-packages.outputs.success == '1' + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 + with: + github_token: ${{ github.token }} + workflow: electron-passkeys.yml + name: electron-passkeys-npm + path: packages/electron-passkeys/npm + branch: main + workflow_conclusion: success + search_artifacts: true + allow_forks: false + + - name: Verify electron-passkeys binaries are present + if: steps.version-packages.outputs.success == '1' + run: node scripts/check-electron-passkeys-binaries.mjs packages/electron-passkeys/npm + + - name: Verify electron-passkeys binaries match the current native sources + if: steps.version-packages.outputs.success == '1' + run: node scripts/electron-passkeys-source-hash.mjs verify + - name: Canary release id: publish if: steps.version-packages.outputs.success == '1' @@ -372,6 +426,7 @@ jobs: timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 10 }} permissions: + actions: read contents: read id-token: write pull-requests: write @@ -437,8 +492,8 @@ jobs: with: cache-enabled: false # OIDC publish job; do not restore poisonable cache state. turbo-enabled: false # Remote cache disabled in OIDC publish path. - turbo-team: "" - turbo-token: "" + turbo-team: '' + turbo-token: '' - name: Upgrade npm for trusted publishing run: npx npm@11 install -g npm@11 @@ -464,6 +519,28 @@ jobs: if: steps.version-packages.outputs.success == '1' run: pnpm turbo build $TURBO_ARGS + - name: Download electron-passkeys native binaries + if: steps.version-packages.outputs.success == '1' + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 + with: + github_token: ${{ github.token }} + workflow: electron-passkeys.yml + name: electron-passkeys-npm + path: packages/electron-passkeys/npm + branch: main + workflow_conclusion: success + search_artifacts: true + allow_forks: false + + - name: Verify electron-passkeys binaries are present + if: steps.version-packages.outputs.success == '1' + run: node scripts/check-electron-passkeys-binaries.mjs packages/electron-passkeys/npm + + # Snapshots always ship main's binaries; a PR with native changes cannot be verified here, so only warn. + - name: Check electron-passkeys binaries against this branch's native sources + if: steps.version-packages.outputs.success == '1' + run: node scripts/electron-passkeys-source-hash.mjs verify --warn + - name: Snapshot release if: steps.version-packages.outputs.success == '1' run: pnpm release:snapshot @@ -570,7 +647,7 @@ jobs: persist-credentials: false fetch-depth: 1 fetch-tags: false - filter: "blob:none" + filter: 'blob:none' show-progress: false - name: Cache node_modules (Node v${{ matrix.version }}) @@ -579,5 +656,5 @@ jobs: cache-enabled: true node-version: ${{ matrix.version }} turbo-enabled: false - turbo-team: "" - turbo-token: "" + turbo-team: '' + turbo-token: '' diff --git a/packages/electron-passkeys/.gitignore b/packages/electron-passkeys/.gitignore index 2b422d37c4e..ac217b3a15e 100644 --- a/packages/electron-passkeys/.gitignore +++ b/packages/electron-passkeys/.gitignore @@ -4,3 +4,4 @@ artifacts/ # napi-generated type defs for the raw native binding (the public surface is # the hand-written index.js/index.d.ts loader) native.d.ts +npm/.source-hash diff --git a/scripts/electron-passkeys-source-hash.mjs b/scripts/electron-passkeys-source-hash.mjs new file mode 100644 index 00000000000..3daf0a7d1f2 --- /dev/null +++ b/scripts/electron-passkeys-source-hash.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const DEFAULT_PACKAGE_DIR = 'packages/electron-passkeys'; +export const DEFAULT_HASH_FILE = 'packages/electron-passkeys/npm/.source-hash'; + +// Inputs that change the compiled binary. Anything else in the package (loader, README, npm/*) does not. +export const SOURCE_INPUTS = ['src', 'Cargo.toml', 'Cargo.lock', 'build.rs']; + +async function collectFiles(path) { + const info = await stat(path); + if (!info.isDirectory()) { + return [path]; + } + const entries = await readdir(path, { withFileTypes: true }); + const nested = await Promise.all(entries.map(entry => collectFiles(join(path, entry.name)))); + return nested.flat(); +} + +export async function computeSourceHash(packageDir) { + const files = (await Promise.all(SOURCE_INPUTS.map(input => collectFiles(resolve(packageDir, input))))).flat(); + const hash = createHash('sha256'); + + for (const file of files.sort()) { + hash.update(relative(packageDir, file).split('\\').join('/')); + hash.update('\0'); + hash.update(await readFile(file)); + hash.update('\0'); + } + + return hash.digest('hex'); +} + +export function formatStaleError(expected, actual) { + return `::error::electron-passkeys binaries were built from source hash ${actual} but the tree is at ${expected}; re-run the "Electron Passkeys Native Build" workflow on this commit`; +} + +export function formatStaleWarning(expected, actual) { + return `::warning::electron-passkeys binaries were built from source hash ${actual} but the tree is at ${expected}; the published binaries do not include this branch's native changes`; +} + +async function main() { + const [command, ...rest] = process.argv.slice(2); + const warnOnly = rest.includes('--warn'); + const positional = rest.filter(arg => !arg.startsWith('--')); + const hashFile = positional[0] || DEFAULT_HASH_FILE; + const packageDir = positional[1] || DEFAULT_PACKAGE_DIR; + const expected = await computeSourceHash(packageDir); + + if (command === 'write') { + await writeFile(hashFile, `${expected}\n`); + console.log(`Wrote source hash ${expected} to ${hashFile}`); + return; + } + + if (command === 'verify') { + let actual; + try { + actual = (await readFile(hashFile, 'utf8')).trim(); + } catch { + console.error( + `::error::${hashFile} is missing; the downloaded electron-passkeys artifact predates source hashing`, + ); + process.exit(1); + } + + if (actual === expected) { + console.log(`electron-passkeys binaries match source hash ${expected}.`); + return; + } + + if (warnOnly) { + console.warn(formatStaleWarning(expected, actual)); + return; + } + + console.error(formatStaleError(expected, actual)); + process.exit(1); + } + + console.error(`Usage: electron-passkeys-source-hash.mjs [hashFile] [packageDir] [--warn]`); + process.exit(1); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + await main(); +} diff --git a/scripts/electron-passkeys-source-hash.test.mjs b/scripts/electron-passkeys-source-hash.test.mjs new file mode 100644 index 00000000000..6a638831db8 --- /dev/null +++ b/scripts/electron-passkeys-source-hash.test.mjs @@ -0,0 +1,69 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, test } from 'vitest'; + +import { computeSourceHash, formatStaleError } from './electron-passkeys-source-hash.mjs'; + +const roots = []; + +async function createPackage(files) { + const root = await mkdtemp(join(tmpdir(), 'electron-passkeys-source-')); + roots.push(root); + + const defaults = { + 'src/lib.rs': 'fn main() {}', + 'Cargo.toml': '[package]', + 'Cargo.lock': '', + 'build.rs': '', + }; + for (const [path, content] of Object.entries({ ...defaults, ...files })) { + await mkdir(join(root, path, '..'), { recursive: true }); + await writeFile(join(root, path), content); + } + + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { force: true, recursive: true }))); +}); + +describe('computeSourceHash', () => { + test('is stable across identical trees in different locations', async () => { + const a = await createPackage({}); + const b = await createPackage({}); + + await expect(computeSourceHash(a)).resolves.toBe(await computeSourceHash(b)); + }); + + test('changes when a Rust source file changes', async () => { + const a = await createPackage({}); + const b = await createPackage({ 'src/lib.rs': 'fn main() { changed(); }' }); + + expect(await computeSourceHash(a)).not.toBe(await computeSourceHash(b)); + }); + + test('changes when Cargo.lock changes', async () => { + const a = await createPackage({}); + const b = await createPackage({ 'Cargo.lock': 'version = 4' }); + + expect(await computeSourceHash(a)).not.toBe(await computeSourceHash(b)); + }); + + test('ignores files outside the native inputs', async () => { + const a = await createPackage({}); + const b = await createPackage({ 'index.js': 'module.exports = {}', 'npm/darwin-x64/package.json': '{}' }); + + await expect(computeSourceHash(a)).resolves.toBe(await computeSourceHash(b)); + }); +}); + +describe('formatStaleError', () => { + test('names both hashes and the remediation', () => { + expect(formatStaleError('bbb', 'aaa')).toBe( + '::error::electron-passkeys binaries were built from source hash aaa but the tree is at bbb; re-run the "Electron Passkeys Native Build" workflow on this commit', + ); + }); +});