Skip to content

Commit cb7fa31

Browse files
authored
fix(ci): harden release and CodeQL checks (#164)
## Related Issue No issue. This maintainer follow-up fixes failed `main` Release run 32620664188 and the linked CodeQL configuration warning. ## Problem The Nix lock freshness check preferred a release branch's stale upstream over the current default branch. Changesets release PRs could therefore fail after they reset onto a newer `main`. The CodeQL analysis also emitted an oversized related-location warning because public OAuth endpoint identifiers flowed directly into a SHA-256 filename fingerprint and were classified as password-like values. ## What changed - Compare lock and flake changes with `origin/HEAD`, then `origin/main`, before a branch upstream. - Cover stale release upstreams and real lock-only feature changes with regression tests. - Encode public endpoint identity as UTF-8 bytes before hashing in both credential-name implementations. This keeps the exact existing digest while preserving the full CodeQL security suite. - Pin the existing scoped credential key in its test to protect compatibility. ## Verification - `pnpm lint` - `pnpm run typecheck` - `pnpm run test` - `pnpm run build` - `nix build .#pythinker-code` - Focused Nix freshness, OAuth, and datasource plugin tests - `pnpm run sherif` - `node scripts/check-nix-workspace.mjs` - Independent security, release-validation, and identifier audits: no findings ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] No related issue is required for this maintainer CI and security follow-up. - [x] I have added tests that prove the fixes work. - [x] Ran `gen-changesets`; no changeset because behavior and published output are unchanged. - [x] No documentation update is needed because the change only repairs internal validation and equivalent digest input encoding. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved release-branch checks to detect stale pinned hashes using the current default remote branch. * Updated OAuth and data-source credential hashing to consistently use UTF-8 encoding. * Refined diagnostics to more accurately describe potentially stale hashes. * **Tests** * Added coverage for release-branch hash validation, including stale tracking and lock-only changes. * Strengthened OAuth key tests with exact expected derived values. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0b3f72d commit cb7fa31

5 files changed

Lines changed: 111 additions & 12 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { execFileSync, spawnSync, type SpawnSyncReturns } from 'node:child_process';
2+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join, resolve } from 'node:path';
5+
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
8+
const checkScript = resolve(import.meta.dirname, '../../../../scripts/check-nix-hash-fresh.mjs');
9+
const tempRoots: string[] = [];
10+
11+
afterEach(() => {
12+
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
13+
});
14+
15+
describe('check-nix-hash-fresh', () => {
16+
it('uses current origin/main when a release branch tracks a stale upstream', () => {
17+
const root = makeRepository('changeset-release/main');
18+
const staleRelease = revParse(root, 'HEAD');
19+
setRemoteRef(root, 'changeset-release/main', staleRelease);
20+
21+
git(root, ['switch', '-c', 'main']);
22+
writeFileSync(join(root, 'pnpm-lock.yaml'), 'lock from current main\n');
23+
commit(root, 'update lock on main');
24+
const currentMain = revParse(root, 'HEAD');
25+
setRemoteRef(root, 'main', currentMain);
26+
27+
git(root, ['switch', 'changeset-release/main']);
28+
git(root, ['reset', '--hard', currentMain]);
29+
writeFileSync(join(root, 'package.json'), '{"version":"1.0.1"}\n');
30+
commit(root, 'version packages');
31+
32+
const result = runCheck(root);
33+
expect(result.status, result.stderr).toBe(0);
34+
});
35+
36+
it('keeps a lock-only branch change gated after that change reaches its upstream', () => {
37+
const root = makeRepository('feature');
38+
const currentMain = revParse(root, 'HEAD');
39+
setRemoteRef(root, 'main', currentMain);
40+
41+
writeFileSync(join(root, 'pnpm-lock.yaml'), 'unmatched lock change\n');
42+
commit(root, 'update lock without flake');
43+
setRemoteRef(root, 'feature', revParse(root, 'HEAD'));
44+
writeFileSync(join(root, 'README.md'), 'follow-up\n');
45+
commit(root, 'add follow-up');
46+
47+
const result = runCheck(root);
48+
expect(result.status, result.stderr).toBe(1);
49+
expect(result.stderr).toContain('pnpm-lock.yaml changed on this branch but flake.nix did not');
50+
});
51+
});
52+
53+
function makeRepository(branch: string): string {
54+
const root = mkdtempSync(join(tmpdir(), 'pythinker-nix-hash-check-'));
55+
tempRoots.push(root);
56+
git(root, ['init', '--initial-branch', branch]);
57+
git(root, ['config', 'core.hooksPath', '.git/no-hooks']);
58+
git(root, ['config', 'user.name', 'Test User']);
59+
git(root, ['config', 'user.email', 'test@example.test']);
60+
git(root, ['config', 'commit.gpgSign', 'false']);
61+
git(root, ['remote', 'add', 'origin', join(root, 'unused-origin.git')]);
62+
writeFileSync(join(root, 'pnpm-lock.yaml'), 'initial lock\n');
63+
writeFileSync(join(root, 'flake.nix'), 'initial flake\n');
64+
commit(root, 'initial');
65+
return root;
66+
}
67+
68+
function commit(root: string, message: string): void {
69+
git(root, ['add', '.']);
70+
git(root, ['commit', '-m', message]);
71+
}
72+
73+
function setRemoteRef(root: string, branch: string, sha: string): void {
74+
git(root, ['update-ref', `refs/remotes/origin/${branch}`, sha]);
75+
git(root, ['config', `branch.${branch}.remote`, 'origin']);
76+
git(root, ['config', `branch.${branch}.merge`, `refs/heads/${branch}`]);
77+
}
78+
79+
function revParse(root: string, ref: string): string {
80+
return git(root, ['rev-parse', ref]);
81+
}
82+
83+
function git(root: string, args: string[]): string {
84+
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
85+
}
86+
87+
function runCheck(root: string): SpawnSyncReturns<string> {
88+
return spawnSync(process.execPath, [checkScript], { cwd: root, encoding: 'utf8' });
89+
}

packages/oauth/src/managed-pythinker-code.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,11 @@ export function resolvePythinkerCodeOAuthKey(options: {
333333
return PYTHINKER_CODE_OAUTH_KEY;
334334
}
335335

336+
const publicEndpointIdentity = new TextEncoder().encode(
337+
JSON.stringify({ oauthHost, baseUrl }),
338+
);
336339
const digest = createHash('sha256')
337-
.update(JSON.stringify({ oauthHost, baseUrl }))
340+
.update(publicEndpointIdentity)
338341
.digest('hex')
339342
.slice(0, 16);
340343
return `${PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`;

packages/oauth/test/managed-pythinker-code.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,7 @@ describe('provisionManagedPythinkerCodeConfig', () => {
6161
baseUrl: 'https://api.dev.example.test/coding/v1',
6262
});
6363

64-
expect(devKey).not.toBe(PYTHINKER_CODE_OAUTH_KEY);
65-
expect(devKey).toMatch(/^oauth\/pythinker-code-env-[a-f0-9]{16}$/);
64+
expect(devKey).toBe('oauth/pythinker-code-env-51d35a57390d1c7e');
6665
expect(
6766
resolvePythinkerCodeOAuthKey({
6867
oauthHost: 'https://auth.dev.example.test/',

plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,11 @@ function resolvePythinkerCodeCredentialName() {
299299
}
300300

301301
// Keep this in sync with packages/oauth/src/managed-pythinker-code.ts.
302+
const publicEndpointIdentity = new TextEncoder().encode(
303+
JSON.stringify({ oauthHost, baseUrl }),
304+
);
302305
const digest = createHash('sha256')
303-
.update(JSON.stringify({ oauthHost, baseUrl }))
306+
.update(publicEndpointIdentity)
304307
.digest('hex')
305308
.slice(0, 16);
306309
return `pythinker-code-env-${digest}`;

scripts/check-nix-hash-fresh.mjs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,20 @@ function git(args) {
1818
}
1919

2020
function resolveBaseRef() {
21-
try {
22-
const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
23-
if (upstream) return upstream;
24-
} catch {
25-
// no upstream configured
21+
for (const [ref, fullRef] of [
22+
['origin/HEAD', 'refs/remotes/origin/HEAD'],
23+
['origin/main', 'refs/remotes/origin/main'],
24+
]) {
25+
try {
26+
git(['show-ref', '--verify', '--quiet', fullRef]);
27+
return ref;
28+
} catch {
29+
// ref is unavailable
30+
}
2631
}
2732
try {
28-
git(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/HEAD']);
29-
return 'origin/HEAD';
33+
const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
34+
return upstream || null;
3035
} catch {
3136
return null;
3237
}
@@ -57,7 +62,7 @@ if (!lockChanged || flakeChanged) {
5762

5863
console.error(
5964
'❌ pnpm-lock.yaml changed on this branch but flake.nix did not.\n' +
60-
" flake.nix pins a fetchPnpmDeps hash of pnpm-lock.yaml; CI's nix build will fail with a hash mismatch.\n" +
65+
' flake.nix pins a fetchPnpmDeps hash of pnpm-lock.yaml, so its hash may be stale.\n' +
6166
' Refresh it: run a nix build (e.g. `nix build .#pythinker-code`), take the sha256-... hash\n' +
6267
' from the mismatch error, paste it into the `hash = "sha256-...";` line in flake.nix, commit, and re-push.',
6368
);

0 commit comments

Comments
 (0)