From f37513c4f08ef46c11d7d387747dd577b00c45b6 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 06:09:51 -0400 Subject: [PATCH] fix(ci): harden release and CodeQL checks --- .../test/scripts/check-nix-hash-fresh.test.ts | 89 +++++++++++++++++++ packages/oauth/src/managed-pythinker-code.ts | 5 +- .../oauth/test/managed-pythinker-code.test.ts | 3 +- .../bin/pythinker-datasource.mjs | 5 +- scripts/check-nix-hash-fresh.mjs | 21 +++-- 5 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 apps/pythinker-code/test/scripts/check-nix-hash-fresh.test.ts diff --git a/apps/pythinker-code/test/scripts/check-nix-hash-fresh.test.ts b/apps/pythinker-code/test/scripts/check-nix-hash-fresh.test.ts new file mode 100644 index 000000000..6a2326338 --- /dev/null +++ b/apps/pythinker-code/test/scripts/check-nix-hash-fresh.test.ts @@ -0,0 +1,89 @@ +import { execFileSync, spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const checkScript = resolve(import.meta.dirname, '../../../../scripts/check-nix-hash-fresh.mjs'); +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('check-nix-hash-fresh', () => { + it('uses current origin/main when a release branch tracks a stale upstream', () => { + const root = makeRepository('changeset-release/main'); + const staleRelease = revParse(root, 'HEAD'); + setRemoteRef(root, 'changeset-release/main', staleRelease); + + git(root, ['switch', '-c', 'main']); + writeFileSync(join(root, 'pnpm-lock.yaml'), 'lock from current main\n'); + commit(root, 'update lock on main'); + const currentMain = revParse(root, 'HEAD'); + setRemoteRef(root, 'main', currentMain); + + git(root, ['switch', 'changeset-release/main']); + git(root, ['reset', '--hard', currentMain]); + writeFileSync(join(root, 'package.json'), '{"version":"1.0.1"}\n'); + commit(root, 'version packages'); + + const result = runCheck(root); + expect(result.status, result.stderr).toBe(0); + }); + + it('keeps a lock-only branch change gated after that change reaches its upstream', () => { + const root = makeRepository('feature'); + const currentMain = revParse(root, 'HEAD'); + setRemoteRef(root, 'main', currentMain); + + writeFileSync(join(root, 'pnpm-lock.yaml'), 'unmatched lock change\n'); + commit(root, 'update lock without flake'); + setRemoteRef(root, 'feature', revParse(root, 'HEAD')); + writeFileSync(join(root, 'README.md'), 'follow-up\n'); + commit(root, 'add follow-up'); + + const result = runCheck(root); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain('pnpm-lock.yaml changed on this branch but flake.nix did not'); + }); +}); + +function makeRepository(branch: string): string { + const root = mkdtempSync(join(tmpdir(), 'pythinker-nix-hash-check-')); + tempRoots.push(root); + git(root, ['init', '--initial-branch', branch]); + git(root, ['config', 'core.hooksPath', '.git/no-hooks']); + git(root, ['config', 'user.name', 'Test User']); + git(root, ['config', 'user.email', 'test@example.test']); + git(root, ['config', 'commit.gpgSign', 'false']); + git(root, ['remote', 'add', 'origin', join(root, 'unused-origin.git')]); + writeFileSync(join(root, 'pnpm-lock.yaml'), 'initial lock\n'); + writeFileSync(join(root, 'flake.nix'), 'initial flake\n'); + commit(root, 'initial'); + return root; +} + +function commit(root: string, message: string): void { + git(root, ['add', '.']); + git(root, ['commit', '-m', message]); +} + +function setRemoteRef(root: string, branch: string, sha: string): void { + git(root, ['update-ref', `refs/remotes/origin/${branch}`, sha]); + git(root, ['config', `branch.${branch}.remote`, 'origin']); + git(root, ['config', `branch.${branch}.merge`, `refs/heads/${branch}`]); +} + +function revParse(root: string, ref: string): string { + return git(root, ['rev-parse', ref]); +} + +function git(root: string, args: string[]): string { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function runCheck(root: string): SpawnSyncReturns { + return spawnSync(process.execPath, [checkScript], { cwd: root, encoding: 'utf8' }); +} diff --git a/packages/oauth/src/managed-pythinker-code.ts b/packages/oauth/src/managed-pythinker-code.ts index 6dbab5ab1..77914cc86 100644 --- a/packages/oauth/src/managed-pythinker-code.ts +++ b/packages/oauth/src/managed-pythinker-code.ts @@ -333,8 +333,11 @@ export function resolvePythinkerCodeOAuthKey(options: { return PYTHINKER_CODE_OAUTH_KEY; } + const publicEndpointIdentity = new TextEncoder().encode( + JSON.stringify({ oauthHost, baseUrl }), + ); const digest = createHash('sha256') - .update(JSON.stringify({ oauthHost, baseUrl })) + .update(publicEndpointIdentity) .digest('hex') .slice(0, 16); return `${PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`; diff --git a/packages/oauth/test/managed-pythinker-code.test.ts b/packages/oauth/test/managed-pythinker-code.test.ts index 9fce5e690..0f91a7eb9 100644 --- a/packages/oauth/test/managed-pythinker-code.test.ts +++ b/packages/oauth/test/managed-pythinker-code.test.ts @@ -61,8 +61,7 @@ describe('provisionManagedPythinkerCodeConfig', () => { baseUrl: 'https://api.dev.example.test/coding/v1', }); - expect(devKey).not.toBe(PYTHINKER_CODE_OAUTH_KEY); - expect(devKey).toMatch(/^oauth\/pythinker-code-env-[a-f0-9]{16}$/); + expect(devKey).toBe('oauth/pythinker-code-env-51d35a57390d1c7e'); expect( resolvePythinkerCodeOAuthKey({ oauthHost: 'https://auth.dev.example.test/', diff --git a/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs b/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs index 06872b127..9fcbe433f 100644 --- a/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs +++ b/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs @@ -299,8 +299,11 @@ function resolvePythinkerCodeCredentialName() { } // Keep this in sync with packages/oauth/src/managed-pythinker-code.ts. + const publicEndpointIdentity = new TextEncoder().encode( + JSON.stringify({ oauthHost, baseUrl }), + ); const digest = createHash('sha256') - .update(JSON.stringify({ oauthHost, baseUrl })) + .update(publicEndpointIdentity) .digest('hex') .slice(0, 16); return `pythinker-code-env-${digest}`; diff --git a/scripts/check-nix-hash-fresh.mjs b/scripts/check-nix-hash-fresh.mjs index 675bfc460..7c7ea3827 100755 --- a/scripts/check-nix-hash-fresh.mjs +++ b/scripts/check-nix-hash-fresh.mjs @@ -18,15 +18,20 @@ function git(args) { } function resolveBaseRef() { - try { - const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); - if (upstream) return upstream; - } catch { - // no upstream configured + for (const [ref, fullRef] of [ + ['origin/HEAD', 'refs/remotes/origin/HEAD'], + ['origin/main', 'refs/remotes/origin/main'], + ]) { + try { + git(['show-ref', '--verify', '--quiet', fullRef]); + return ref; + } catch { + // ref is unavailable + } } try { - git(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/HEAD']); - return 'origin/HEAD'; + const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); + return upstream || null; } catch { return null; } @@ -57,7 +62,7 @@ if (!lockChanged || flakeChanged) { console.error( '❌ pnpm-lock.yaml changed on this branch but flake.nix did not.\n' + - " flake.nix pins a fetchPnpmDeps hash of pnpm-lock.yaml; CI's nix build will fail with a hash mismatch.\n" + + ' flake.nix pins a fetchPnpmDeps hash of pnpm-lock.yaml, so its hash may be stale.\n' + ' Refresh it: run a nix build (e.g. `nix build .#pythinker-code`), take the sha256-... hash\n' + ' from the mismatch error, paste it into the `hash = "sha256-...";` line in flake.nix, commit, and re-push.', );