Skip to content

Commit 1434b37

Browse files
committed
Fix CI failures and harden pre-push gates
- Unify smol-toml to ^1.7.1 (sherif monorepo version skew) - Refresh flake.nix fetchPnpmDeps hash for the updated lockfile - Native darwin bundles: build unsigned with a warning when Apple signing secrets are absent; malformed secrets still fail loudly - Add dependabot.yml: ignore docs-only vitepress-pinned vite/esbuild advisories that have no reachable patch - New pre-push hook: co-authorship guard, sherif, oxlint, nix-hash freshness proxy, changed-scoped typecheck and vitest (SKIP_HOOKS=1 escape hatch)
1 parent 134f1eb commit 1434b37

7 files changed

Lines changed: 211 additions & 8 deletions

File tree

.github/dependabot.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "npm"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
# Scheduled version-update PRs are disabled on purpose: this workspace's
8+
# engines.node (>=26.4.0) exceeds the Node.js version Dependabot's own
9+
# updater sandbox ships, so ANY `pnpm install`/update it attempts hard-fails
10+
# with ERR_PNPM_UNSUPPORTED_ENGINE (root .npmrc sets engine-strict=true).
11+
# See dependabot/dependabot-core#7426, #4072, #12976 — there is no
12+
# dependabot.yml-level fix; the real fix is lowering engines.node or
13+
# disabling engine-strict in .npmrc, both out of scope here. This entry
14+
# exists only so the `ignore` rules below apply to security-update PRs,
15+
# which open-pull-requests-limit: 0 does not suppress.
16+
open-pull-requests-limit: 0
17+
ignore:
18+
# Both advisories are on vite/esbuild resolved transitively through
19+
# docs/package.json's vitepress@1.6.4 (vite@5.4.21 in pnpm-lock.yaml),
20+
# a dev-only docs-site dependency. vitepress@1.6.4 pins `vite: 5.4.21`
21+
# as a hard (non-peer) dependency, so pnpm cannot resolve a fix without
22+
# vitepress 2 (alpha only as of 2026-08) — every attempt reopens the
23+
# same failing PR. Unscoped by version on purpose: Dependabot's actual
24+
# target version for this resolution is unverifiable (no open
25+
# Dependabot PRs exist in this repo to read a target off of, and the
26+
# target is not necessarily the advisory's first-patched version), so a
27+
# version-scoped rule can silently fail to match and let the PR back
28+
# in. Cost: this also suppresses vite/esbuild security-update PRs for
29+
# apps/*, which today sit at patched vite@6.4.3+ directly — revisit if
30+
# that changes. Drop both rules once docs/package.json moves to
31+
# vitepress 2 stable.
32+
- dependency-name: "vite"
33+
- dependency-name: "esbuild"

.github/workflows/_native-build.yml

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ jobs:
5454
- os: windows-11-arm
5555
target: win32-arm64
5656

57+
env:
58+
# secrets.* isn't allowed in `if:` conditions, so resolve the effective
59+
# signing gate here (job-level env does allow secrets) and branch on the
60+
# env var in steps below. Absent/empty secrets -> build unsigned + warn,
61+
# not a hard failure; a malformed present secret still fails loudly
62+
# inside macos-keychain-setup (bad base64 / no identity found).
63+
APPLE_SIGNING_AVAILABLE: ${{ inputs.sign-macos && secrets.APPLE_CERTIFICATE_P12 != '' && secrets.APPLE_CERTIFICATE_PASSWORD != '' }}
64+
5765
steps:
5866
- name: Checkout
5967
uses: actions/checkout@v6
@@ -70,8 +78,12 @@ jobs:
7078
- name: Install dependencies
7179
run: pnpm install --frozen-lockfile
7280

81+
- name: Warn if macOS signing requested but secrets are absent
82+
if: runner.os == 'macOS' && inputs.sign-macos && env.APPLE_SIGNING_AVAILABLE != 'true'
83+
run: echo "::warning::APPLE_CERTIFICATE_P12/APPLE_CERTIFICATE_PASSWORD not configured — building an unsigned, unnotarized darwin bundle."
84+
7385
- name: Setup macOS keychain (release only)
74-
if: runner.os == 'macOS' && inputs.sign-macos
86+
if: runner.os == 'macOS' && env.APPLE_SIGNING_AVAILABLE == 'true'
7587
uses: ./.github/actions/macos-keychain-setup
7688
with:
7789
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
@@ -93,15 +105,15 @@ jobs:
93105
node apps/pythinker-code/scripts/copy-web-assets.mjs
94106
95107
- name: Build native executable (release profile, macOS signed)
96-
if: runner.os == 'macOS' && inputs.sign-macos
108+
if: runner.os == 'macOS' && env.APPLE_SIGNING_AVAILABLE == 'true'
97109
run: pnpm --filter @pythoughts/pythinker-code run build:native:release
98110

99111
- name: Build native executable (local profile)
100-
if: '!(runner.os == ''macOS'' && inputs.sign-macos)'
112+
if: '!(runner.os == ''macOS'' && env.APPLE_SIGNING_AVAILABLE == ''true'')'
101113
run: pnpm --filter @pythoughts/pythinker-code run build:native:sea
102114

103115
- name: Notarize macOS binary
104-
if: runner.os == 'macOS' && inputs.sign-macos
116+
if: runner.os == 'macOS' && env.APPLE_SIGNING_AVAILABLE == 'true'
105117
uses: ./.github/actions/macos-notarize
106118
with:
107119
binary-path: ${{ github.workspace }}/apps/pythinker-code/dist-native/bin/${{ matrix.target }}/pythinker
@@ -110,7 +122,7 @@ jobs:
110122
notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
111123

112124
- name: Cleanup macOS keychain
113-
if: always() && runner.os == 'macOS' && inputs.sign-macos
125+
if: always() && runner.os == 'macOS' && env.APPLE_SIGNING_AVAILABLE == 'true'
114126
uses: ./.github/actions/macos-keychain-cleanup
115127

116128
- name: Smoke test native executable

apps/pythinker-code/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
"pathe": "^2.0.3",
116116
"postject": "1.0.0-alpha.6",
117117
"semver": "^7.7.4",
118-
"smol-toml": "^1.6.1",
118+
"smol-toml": "^1.7.1",
119119
"tsx": "^4.23.5",
120120
"unplugin-solid": "1.0.0",
121121
"vite": "^6.4.3",

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
inherit (finalAttrs) pname version src pnpmWorkspaces;
149149
inherit pnpm;
150150
fetcherVersion = 3;
151-
hash = "sha256-UrOkRsDqap6OgpDzaJKK/HqGUqnyyndasMs2dNQD/OY=";
151+
hash = "sha256-zTjp/On758iWGAWAYAIBUXF+pRkUWsUSOg/38LHoCw4=";
152152
};
153153

154154
nativeBuildInputs = [

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"vitest": "4.1.9"
5151
},
5252
"simple-git-hooks": {
53-
"pre-push": "bash scripts/git-hooks/pre-push-no-ai-coauthorship.sh"
53+
"pre-push": "bash scripts/pre-push.sh"
5454
},
5555
"lint-staged": {
5656
"*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": [

scripts/check-nix-hash-fresh.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env node
2+
// Cheap proxy for the Nix fetchPnpmDeps hash going stale.
3+
//
4+
// flake.nix pins a `hash = "sha256-..."` for pkgs.fetchPnpmDeps, computed
5+
// from pnpm-lock.yaml. If the lockfile changes and nobody refreshes that
6+
// hash, `nix build` fails in CI with a hash mismatch. Recomputing the real
7+
// hash requires `nix`, which may not be installed locally, so this checks a
8+
// proxy instead: did pnpm-lock.yaml change on this branch without flake.nix
9+
// also changing? That's not proof the hash is stale (a flake.nix edit could
10+
// be unrelated), but it's the cheap signal that catches the common case.
11+
//
12+
// ponytail: proxy check, not a real hash recompute — upgrade to `nix build
13+
// --dry-run` locally if false positives/negatives become a problem.
14+
import { execFileSync } from 'node:child_process';
15+
16+
function git(args) {
17+
return execFileSync('git', args, { encoding: 'utf8' }).trim();
18+
}
19+
20+
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
26+
}
27+
try {
28+
git(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/HEAD']);
29+
return 'origin/HEAD';
30+
} catch {
31+
return null;
32+
}
33+
}
34+
35+
const base = resolveBaseRef();
36+
if (!base) {
37+
console.log('[nix-hash-freshness] no upstream/origin ref found, skipping (bootstrap push).');
38+
process.exit(0);
39+
}
40+
41+
let changedFiles;
42+
try {
43+
changedFiles = git(['diff', '--name-only', `${base}...HEAD`])
44+
.split('\n')
45+
.filter(Boolean);
46+
} catch (err) {
47+
console.log(`[nix-hash-freshness] could not diff against ${base}, skipping. (${err.message})`);
48+
process.exit(0);
49+
}
50+
51+
const lockChanged = changedFiles.includes('pnpm-lock.yaml');
52+
const flakeChanged = changedFiles.includes('flake.nix');
53+
54+
if (!lockChanged || flakeChanged) {
55+
process.exit(0);
56+
}
57+
58+
console.error(
59+
'❌ 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" +
61+
' Refresh it: run a nix build (e.g. `nix build .#pythinker-code`), take the sha256-... hash\n' +
62+
' from the mismatch error, paste it into the `hash = "sha256-...";` line in flake.nix, commit, and re-push.',
63+
);
64+
process.exit(1);

scripts/pre-push.sh

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env bash
2+
# Pre-push gate: catches classes of CI failure locally before they burn a CI run.
3+
# Cheapest/most-certain checks run first and fail fast.
4+
#
5+
# Escape hatch: SKIP_HOOKS=1 git push (or git push --no-verify)
6+
set -euo pipefail
7+
8+
if [[ "${SKIP_HOOKS:-0}" == "1" ]]; then
9+
echo "[pre-push] SKIP_HOOKS=1 set, skipping all checks."
10+
exit 0
11+
fi
12+
13+
cd "$(git rev-parse --show-toplevel)"
14+
15+
start_ts=$(date +%s)
16+
17+
# git's pre-push protocol feeds "<local ref> <local oid> <remote ref> <remote oid>"
18+
# lines on stdin. Capture once so any sub-check that needs it (the AI
19+
# co-authorship scan) can reuse it.
20+
stdin_file="$(mktemp)"
21+
trap 'rm -f "$stdin_file"' EXIT
22+
cat > "$stdin_file"
23+
24+
fail() {
25+
local job="$1" msg="$2"
26+
local elapsed=$(( $(date +%s) - start_ts ))
27+
echo "❌ pre-push: would fail CI job \`${job}\`${msg}" >&2
28+
echo " Escape hatch: SKIP_HOOKS=1 git push (or git push --no-verify)" >&2
29+
echo "[pre-push] failed after ${elapsed}s" >&2
30+
exit 1
31+
}
32+
33+
step() {
34+
local name="$1"
35+
shift
36+
echo "[pre-push] running: ${name}"
37+
"$@"
38+
}
39+
40+
# What's already on the remote — used to scope diff-based checks (nix hash,
41+
# changed-package typecheck, changed-test selection). Bootstrap-safe: falls
42+
# back to origin/HEAD, then to skipping those checks entirely.
43+
resolve_base_ref() {
44+
local upstream
45+
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)
46+
if [[ -n "$upstream" ]] && git rev-parse --verify --quiet "$upstream" >/dev/null 2>&1; then
47+
echo "$upstream"
48+
return 0
49+
fi
50+
if git show-ref --verify --quiet refs/remotes/origin/HEAD; then
51+
echo "origin/HEAD"
52+
return 0
53+
fi
54+
return 1
55+
}
56+
base_ref="$(resolve_base_ref || true)"
57+
58+
# 1. AI co-authorship attribution — sub-second, blocks known-bad commits outright.
59+
step "no-ai-coauthorship" bash scripts/git-hooks/pre-push-no-ai-coauthorship.sh < "$stdin_file" ||
60+
fail "pre-push-no-ai-coauthorship" "a commit contains forbidden AI agent attribution"
61+
62+
# 2. sherif — monorepo dependency-version skew (CI: `pnpm run sherif`).
63+
step "sherif" pnpm run sherif || fail "sherif" "monorepo dependency versions are out of sync"
64+
65+
# 3. oxlint (CI: `pnpm run lint`).
66+
step "lint" pnpm run lint || fail "lint" "oxlint reported errors"
67+
68+
# 4. Nix fetchPnpmDeps hash freshness proxy (CI: nix build).
69+
step "nix-hash-freshness" node scripts/check-nix-hash-fresh.mjs ||
70+
fail "nix build (flake.nix)" "pnpm-lock.yaml changed without refreshing flake.nix's pnpmDeps hash"
71+
72+
# 5. Typecheck, scoped to packages changed since $base_ref (CI: `pnpm run
73+
# typecheck`). Full typecheck builds every package first and takes
74+
# minutes — not affordable pre-push, so this narrows to what pnpm's own
75+
# dependency graph says was actually touched.
76+
if [[ -n "$base_ref" ]]; then
77+
step "typecheck (changed packages)" pnpm -r --filter "...[${base_ref}]" --if-present run typecheck ||
78+
fail "typecheck" "type errors in a package changed on this branch"
79+
else
80+
echo "[pre-push] no base ref (new branch, no origin/HEAD) — skipping scoped typecheck."
81+
echo "[pre-push] run 'pnpm run typecheck' manually before pushing if you touched types."
82+
fi
83+
84+
# 6. Tests, scoped via vitest's own changed-file impact analysis (CI: `pnpm test`).
85+
if [[ -n "$base_ref" ]]; then
86+
step "test (changed)" pnpm exec vitest run --changed "$base_ref" --passWithNoTests ||
87+
fail "test" "tests affected by changed files are failing"
88+
else
89+
echo "[pre-push] no base ref — skipping scoped tests."
90+
echo "[pre-push] run 'pnpm test' manually before pushing if you touched behavior."
91+
fi
92+
93+
elapsed=$(( $(date +%s) - start_ts ))
94+
echo "[pre-push] all checks passed in ${elapsed}s"

0 commit comments

Comments
 (0)