feat(notion): add markdown block payload conversion #3939
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Generated file - DO NOT EDIT | |
| # Source: ci.yml.genie.ts | |
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| measurement_baseline_ref: | |
| description: Optional ref/SHA to checkout before running CI measurement jobs. Used to backfill comparable baseline artifacts. | |
| required: false | |
| default: '' | |
| type: string | |
| measurement_baseline_label: | |
| description: Optional human label for a measurement baseline backfill run, for example PR number. | |
| required: false | |
| default: '' | |
| type: string | |
| run_datasource_sync_demo: | |
| description: Run the credentialed notion-datasource-sync demo showcase in the Notion integration lane. Requires NOTION_DATASOURCE_SYNC_DEMO_PAGE_ID. | |
| required: false | |
| default: false | |
| type: boolean | |
| debug_force_nix_diagnostics_failure: | |
| description: 'Temporary debug switch (#272): force post-validation failure to verify diagnostics artifact + summary' | |
| required: false | |
| default: false | |
| type: boolean | |
| jobs: | |
| default-ref-policy: | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Check first-party default refs | |
| env: | |
| FIRST_PARTY_OWNERS_JSON: '["schickling","overengineeringstudio"]' | |
| DEFAULT_REF: main | |
| DEFAULT_REFS_JSON: '{"livestorejs/livestore":"dev"}' | |
| VERIFY_REACHABLE: '0' | |
| NORMALIZE_GIT_BRANCH_REFS: '0' | |
| run: | | |
| nix shell nixpkgs#nodejs_24 -c node <<'NODE' | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const os = require('node:os') | |
| const cp = require('node:child_process') | |
| const cwd = process.cwd() | |
| const firstPartyOwners = new Set(JSON.parse(process.env.FIRST_PARTY_OWNERS_JSON).map((owner) => owner.toLowerCase())) | |
| const defaultRef = process.env.DEFAULT_REF || 'main' | |
| const defaultRefs = new Map(Object.entries(JSON.parse(process.env.DEFAULT_REFS_JSON || '{}')).map(([repo, ref]) => [repo.toLowerCase(), ref])) | |
| const verifyReachable = process.env.VERIFY_REACHABLE === '1' | |
| const violations = [] | |
| const repoKey = (repo) => repo.owner.toLowerCase() + '/' + repo.repo.toLowerCase() | |
| const isFirstParty = (repo) => firstPartyOwners.has(repo.owner.toLowerCase()) | |
| const normalizeGitBranchRefs = process.env.NORMALIZE_GIT_BRANCH_REFS === '1' | |
| const normalizeRef = (ref) => normalizeGitBranchRefs && ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref | |
| const expectedRefFor = (repo) => normalizeRef(defaultRefs.get(repoKey(repo)) || defaultRef) | |
| const githubRepoFromUrl = (url) => { | |
| const match = url.match(/github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[#?].*)?$/) | |
| return match ? { owner: match[1], repo: match[2] } : undefined | |
| } | |
| const parseGithubLikeRef = (value) => { | |
| const hashIndex = value.indexOf('#') | |
| const baseWithQuery = hashIndex >= 0 ? value.slice(0, hashIndex) : value | |
| const hashRef = hashIndex >= 0 ? value.slice(hashIndex + 1) : undefined | |
| const queryIndex = baseWithQuery.indexOf('?') | |
| const base = queryIndex >= 0 ? baseWithQuery.slice(0, queryIndex) : baseWithQuery | |
| const params = new URLSearchParams(queryIndex >= 0 ? baseWithQuery.slice(queryIndex + 1) : '') | |
| const queryRef = params.get('ref') || undefined | |
| if (base.startsWith('github:')) { | |
| const parts = base.slice('github:'.length).split('/') | |
| if (parts.length < 2) return undefined | |
| return { | |
| repo: { owner: parts[0], repo: parts[1] }, | |
| ref: hashRef || queryRef || (parts.length > 2 ? parts.slice(2).join('/') : undefined), | |
| } | |
| } | |
| if (base.startsWith('git+https://github.com/')) { | |
| const repo = githubRepoFromUrl(base.slice('git+'.length)) | |
| return repo ? { repo, ref: hashRef || queryRef } : undefined | |
| } | |
| if (base.startsWith('git+ssh://git@github.com/')) { | |
| const pathParts = base.slice('git+ssh://git@github.com/'.length).replace(/\.git$/, '').split('/') | |
| return pathParts.length >= 2 | |
| ? { repo: { owner: pathParts[0], repo: pathParts[1] }, ref: hashRef || queryRef } | |
| : undefined | |
| } | |
| const urlRepo = githubRepoFromUrl(base) | |
| if (urlRepo) return { repo: urlRepo, ref: hashRef || queryRef } | |
| if (!value.includes('://') && !value.startsWith('./') && !value.startsWith('../') && !value.startsWith('/')) { | |
| const parts = base.split('/') | |
| if (parts.length === 2 && parts[0] && parts[1]) { | |
| return { repo: { owner: parts[0], repo: parts[1] }, ref: hashRef } | |
| } | |
| } | |
| return undefined | |
| } | |
| const addRefViolation = ({ file, repo, ref, field, inputName, memberName }) => { | |
| if (!repo || !ref || !isFirstParty(repo)) return | |
| const expectedRef = expectedRefFor(repo) | |
| const normalizedRef = normalizeRef(ref) | |
| if (normalizedRef === expectedRef) return | |
| violations.push({ | |
| type: 'ref', | |
| file, | |
| repo: repoKey(repo), | |
| ref: normalizedRef, | |
| expectedRef, | |
| field, | |
| inputName, | |
| memberName, | |
| }) | |
| } | |
| const readJson = (file) => { | |
| try { | |
| return JSON.parse(fs.readFileSync(file, 'utf8')) | |
| } catch { | |
| return undefined | |
| } | |
| } | |
| const displayPath = (file) => path.relative(cwd, file) || path.basename(file) | |
| const authorityNames = new Set(['megarepo.kdl', 'megarepo.json', 'megarepo.lock', 'flake.nix', 'flake.lock', 'devenv.yaml', 'devenv.lock']) | |
| const ignoredDirs = new Set(['.git', 'node_modules', 'dist', 'result', '.devenv', '.direnv', '.next', '.storybook-static']) | |
| const collectAuthorityFiles = (root) => { | |
| const files = [] | |
| const walk = (dir) => { | |
| let entries = [] | |
| try { | |
| entries = fs.readdirSync(dir) | |
| } catch { | |
| return | |
| } | |
| for (const entry of entries) { | |
| const full = path.join(dir, entry) | |
| let stat | |
| try { | |
| stat = fs.statSync(full) | |
| } catch { | |
| continue | |
| } | |
| if (stat.isDirectory()) { | |
| if (ignoredDirs.has(entry)) continue | |
| if (dir === root && entry === 'repos') continue | |
| walk(full) | |
| } else if (stat.isFile() && authorityNames.has(entry)) { | |
| files.push(full) | |
| } | |
| } | |
| } | |
| walk(root) | |
| return files | |
| } | |
| const extractFlakeNixInputs = (content) => { | |
| const inputs = [] | |
| const directPattern = /(?:inputs\.)?([a-zA-Z0-9_-]+)\.url\s*=\s*"([^"]+)"/g | |
| let match | |
| while ((match = directPattern.exec(content)) !== null) { | |
| inputs.push({ inputName: match[1], url: match[2] }) | |
| } | |
| const lines = content.split('\n') | |
| let inInputs = false | |
| let inputsIndent = -1 | |
| let currentInputName | |
| let currentInputIndent = -1 | |
| for (const line of lines) { | |
| const trimmed = line.trimStart() | |
| const indent = line.length - trimmed.length | |
| if (trimmed.startsWith('inputs = {')) { | |
| inInputs = true | |
| inputsIndent = indent | |
| currentInputName = undefined | |
| currentInputIndent = -1 | |
| continue | |
| } | |
| if (!inInputs) continue | |
| if (trimmed && indent <= inputsIndent && trimmed.startsWith('};')) { | |
| inInputs = false | |
| currentInputName = undefined | |
| continue | |
| } | |
| if (currentInputName && trimmed === '};' && indent <= currentInputIndent) { | |
| currentInputName = undefined | |
| continue | |
| } | |
| const inputStart = trimmed.match(/^(?:inputs\.)?([a-zA-Z0-9_-]+)\s*=\s*\{\s*$/) | |
| if (inputStart && indent > inputsIndent) { | |
| currentInputName = inputStart[1] | |
| currentInputIndent = indent | |
| continue | |
| } | |
| const urlMatch = trimmed.match(/^url\s*=\s*"([^"]+)"/) | |
| if (currentInputName && urlMatch) inputs.push({ inputName: currentInputName, url: urlMatch[1] }) | |
| } | |
| return inputs | |
| } | |
| const extractDevenvYamlInputs = (content) => { | |
| const inputs = [] | |
| const lines = content.split('\n') | |
| let inInputs = false | |
| let inputsIndent = -1 | |
| let currentInputName | |
| for (const line of lines) { | |
| const trimmed = line.trimStart() | |
| const indent = line.length - trimmed.length | |
| if (trimmed === 'inputs:' && !inInputs) { | |
| inInputs = true | |
| inputsIndent = indent | |
| currentInputName = undefined | |
| continue | |
| } | |
| if (!inInputs) continue | |
| if (trimmed && indent <= inputsIndent && !trimmed.startsWith('#')) { | |
| inInputs = false | |
| currentInputName = undefined | |
| continue | |
| } | |
| const inputNameMatch = trimmed.match(/^([a-zA-Z0-9_-]+):$/) | |
| if (inputNameMatch && indent > inputsIndent) { | |
| currentInputName = inputNameMatch[1] | |
| continue | |
| } | |
| const urlMatch = trimmed.match(/^url:\s*(.+)$/) | |
| if (currentInputName && urlMatch) { | |
| const raw = urlMatch[1].trim() | |
| const url = (raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")) | |
| ? raw.slice(1, -1) | |
| : raw | |
| inputs.push({ inputName: currentInputName, url }) | |
| } | |
| } | |
| return inputs | |
| } | |
| const lockInputs = (content) => { | |
| const parsed = readJsonContent(content) | |
| const rootName = parsed && (parsed.root || 'root') | |
| const root = parsed && parsed.nodes && parsed.nodes[rootName] | |
| if (!root || !root.inputs || !parsed.nodes) return [] | |
| return Object.entries(root.inputs).flatMap(([inputName, nodeName]) => { | |
| const node = parsed.nodes[nodeName] | |
| return node ? [{ inputName, original: node.original, locked: node.locked }] : [] | |
| }) | |
| } | |
| const readJsonContent = (content) => { | |
| try { | |
| return JSON.parse(content) | |
| } catch { | |
| return undefined | |
| } | |
| } | |
| const repoFromLockSection = (section) => { | |
| if (!section || typeof section !== 'object') return undefined | |
| if (section.type === 'github' && typeof section.owner === 'string' && typeof section.repo === 'string') { | |
| return { owner: section.owner, repo: section.repo } | |
| } | |
| if (section.type === 'git' && typeof section.url === 'string') return githubRepoFromUrl(section.url) | |
| if (typeof section.url === 'string') return parseGithubLikeRef(section.url)?.repo || githubRepoFromUrl(section.url) | |
| return undefined | |
| } | |
| const refFromLockSection = (section) => { | |
| if (!section || typeof section !== 'object') return undefined | |
| if (typeof section.ref === 'string') return section.ref | |
| if (typeof section.url === 'string') return parseGithubLikeRef(section.url)?.ref | |
| return undefined | |
| } | |
| const roots = [cwd] | |
| const reposRoot = path.join(cwd, 'repos') | |
| if (fs.existsSync(reposRoot)) { | |
| for (const entry of fs.readdirSync(reposRoot)) { | |
| roots.push(path.join(reposRoot, entry)) | |
| } | |
| } | |
| for (const root of roots) { | |
| for (const file of collectAuthorityFiles(root)) { | |
| const base = path.basename(file) | |
| const rel = displayPath(file) | |
| const content = fs.readFileSync(file, 'utf8') | |
| if (base === 'megarepo.kdl') { | |
| for (const line of content.split('\n')) { | |
| const match = line.trim().match(/^([A-Za-z0-9_.-]+)\s+"([^"]+)"/) | |
| if (!match) continue | |
| const parsed = parseGithubLikeRef(match[2]) | |
| addRefViolation({ file: rel, repo: parsed && parsed.repo, ref: parsed && parsed.ref, field: 'members.source', memberName: match[1] }) | |
| } | |
| } else if (base === 'megarepo.lock') { | |
| const parsed = readJson(file) | |
| for (const [memberName, member] of Object.entries((parsed && parsed.members) || {})) { | |
| const repo = typeof member.url === 'string' ? githubRepoFromUrl(member.url) : undefined | |
| addRefViolation({ file: rel, repo, ref: member.ref, field: 'members.ref', memberName }) | |
| } | |
| } else if (base === 'flake.nix') { | |
| for (const input of extractFlakeNixInputs(content)) { | |
| const parsed = parseGithubLikeRef(input.url) | |
| addRefViolation({ file: rel, repo: parsed && parsed.repo, ref: parsed && parsed.ref, field: 'url.ref', inputName: input.inputName }) | |
| } | |
| } else if (base === 'devenv.yaml') { | |
| for (const input of extractDevenvYamlInputs(content)) { | |
| const parsed = parseGithubLikeRef(input.url) | |
| addRefViolation({ file: rel, repo: parsed && parsed.repo, ref: parsed && parsed.ref, field: 'url.ref', inputName: input.inputName }) | |
| } | |
| } else if (base === 'flake.lock' || base === 'devenv.lock') { | |
| for (const input of lockInputs(content)) { | |
| for (const [field, section] of [['original', input.original], ['locked', input.locked]]) { | |
| addRefViolation({ file: rel, repo: repoFromLockSection(section), ref: refFromLockSection(section), field: field + '.ref', inputName: input.inputName }) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if (verifyReachable) { | |
| const lock = readJson(path.join(cwd, 'megarepo.lock')) | |
| for (const [memberName, member] of Object.entries((lock && lock.members) || {})) { | |
| const repo = typeof member.url === 'string' ? githubRepoFromUrl(member.url) : undefined | |
| if (!repo || !isFirstParty(repo)) continue | |
| const expectedRef = expectedRefFor(repo) | |
| const remote = 'https://github.com/' + repo.owner + '/' + repo.repo + '.git' | |
| const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'default-ref-policy-')) | |
| let ok = false | |
| try { | |
| cp.execFileSync('git', ['-C', tmp, 'init', '-q'], { stdio: 'ignore' }) | |
| cp.execFileSync('git', ['-C', tmp, 'remote', 'add', 'origin', remote], { stdio: 'ignore' }) | |
| cp.execFileSync('git', ['-C', tmp, 'fetch', '-q', '--filter=blob:none', 'origin', 'refs/heads/' + expectedRef + ':refs/remotes/origin/' + expectedRef], { stdio: 'ignore' }) | |
| cp.execFileSync('git', ['-C', tmp, 'cat-file', '-e', member.commit + '^{commit}'], { stdio: 'ignore' }) | |
| cp.execFileSync('git', ['-C', tmp, 'merge-base', '--is-ancestor', member.commit, 'refs/remotes/origin/' + expectedRef], { stdio: 'ignore' }) | |
| ok = true | |
| } catch { | |
| ok = false | |
| } finally { | |
| fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) | |
| } | |
| if (!ok) { | |
| violations.push({ type: 'reachability', file: 'megarepo.lock', repo: repoKey(repo), rev: member.commit, expectedRef, memberName }) | |
| } | |
| } | |
| } | |
| if (violations.length === 0) { | |
| console.log('Default ref policy OK') | |
| process.exit(0) | |
| } | |
| console.error('Default ref policy failed:') | |
| for (const violation of violations) { | |
| if (violation.type === 'reachability') { | |
| console.error(' - ' + violation.file + ': member ' + violation.memberName + ' ' + violation.repo + ' locks ' + violation.rev.slice(0, 12) + " outside '" + violation.expectedRef + "'") | |
| } else { | |
| const name = violation.memberName ? ' member ' + violation.memberName : violation.inputName ? ' input ' + violation.inputName : '' | |
| console.error(' - ' + violation.file + ':' + name + ' ' + violation.repo + " uses ref '" + violation.ref + "' in " + violation.field + "; expected '" + violation.expectedRef + "'") | |
| } | |
| } | |
| console.error('') | |
| console.error('Fix: merge upstream PRs first, retarget first-party inputs back to their default refs, then refresh locks.') | |
| process.exit(1) | |
| NODE | |
| shell: bash | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-default-ref-policy" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| typecheck: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Verify OTEL shell entry | |
| shell: bash | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run otel:test --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run otel:test --mode before' | |
| command -v script >/dev/null 2>&1 | |
| tmp_log="$(mktemp)" | |
| printf 'printf "OTEL_MODE=%%s\n" "$OTEL_MODE" | |
| printf "OTEL_GRAFANA_LINK_URL=%%s\n" "$OTEL_GRAFANA_LINK_URL" | |
| exit | |
| ' | script -qefc '"${DEVENV_BIN:?DEVENV_BIN not set}" shell --no-reload' "$tmp_log" | |
| grep -q '\[otel\] Using .* OTEL stack' "$tmp_log" | |
| grep -q '\[otel\] Start with: devenv up' "$tmp_log" | |
| grep -q '^OTEL_MODE=' "$tmp_log" | |
| grep -q '^OTEL_GRAFANA_LINK_URL=http' "$tmp_log" | |
| rm -f "$tmp_log" | |
| - name: Type check | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run ts:check:strict --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run ts:check:strict --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-typecheck" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| lint: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Format + lint | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run lint:check --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run lint:check --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-lint" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| test: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| runner: [namespace-profile-linux-x86-64, namespace-profile-macos-arm64] | |
| runs-on: ['${{ matrix.runner }}', 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Unit tests | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run test:run --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run test:run --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-test-${{ strategy.job-index }}" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| nix-check: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| runner: [namespace-profile-linux-x86-64, namespace-profile-macos-arm64] | |
| runs-on: ['${{ matrix.runner }}', 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Nix hash check | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run nix:check --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run nix:check --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-nix-check-${{ strategy.job-index }}" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| nix-fod-check: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| runner: [namespace-profile-linux-x86-64, namespace-profile-macos-arm64] | |
| runs-on: ['${{ matrix.runner }}', 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Cold pnpm deps validation | |
| shell: bash | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'Cold pnpm deps validation' 'set -euo pipefail | |
| for attr in '"'"'.#genie-pnpm-deps'"'"' '"'"'.#megarepo-pnpm-deps'"'"' '"'"'.#oxc-config-plugin-pnpm-deps'"'"'; do | |
| echo "::group::rebuild-check $attr" | |
| # Step 1: Realize once (may substitute) so rebuild has a trusted output to compare against. | |
| nix build --no-link "$attr" --option substituters '"'"'https://cache.nixos.org'"'"' | |
| # Step 2: Rebuild and compare locally. This fails on stale fixed-output hashes without | |
| # relying on whether a shared daemon store made the prior out path disappear. | |
| nix build --no-link --rebuild "$attr" --option substituters '"'"'https://cache.nixos.org'"'"' | |
| echo "::endgroup::" | |
| done' | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-nix-fod-check-${{ strategy.job-index }}" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| pnpm-builder-contract: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Audit native dependency policy | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| audit=genie/ci-scripts/native-dep-policy-audit.ts | |
| if command -v bun >/dev/null 2>&1; then | |
| bun "$audit" | |
| else | |
| nix run nixpkgs#bun -- "$audit" | |
| fi | |
| - name: Guard pnpm builder contract | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| builder='nix/workspace-tools/lib/mk-pnpm-deps.nix' | |
| policy='nix/workspace-tools/lib/pnpm-install-policy.nix' | |
| if [ ! -f "$builder" ]; then | |
| echo "::error::missing pnpm deps builder: $builder" | |
| exit 1 | |
| fi | |
| if [ ! -f "$policy" ]; then | |
| echo "::error::missing pnpm install policy: $policy" | |
| exit 1 | |
| fi | |
| for required in \ | |
| 'store-dir=%s' \ | |
| 'frozenLockfile ? true' \ | |
| 'pnpm install --frozen-lockfile --ignore-scripts'; do | |
| if ! grep -Fq -- "$required" "$builder"; then | |
| echo "::error::missing required pnpm builder contract fragment: $required" | |
| exit 1 | |
| fi | |
| done | |
| for required in \ | |
| 'side-effects-cache=false' \ | |
| 'verify-store-integrity=true' \ | |
| 'package-import-method=${packageImportMethod}' \ | |
| 'pm-on-fail=ignore' \ | |
| 'strict-store-pkg-content-check=true' \ | |
| 'child-concurrency=1' \ | |
| 'network-concurrency=4'; do | |
| if ! grep -Fq -- "$required" "$policy"; then | |
| echo "::error::missing required pnpm policy contract fragment: $required" | |
| exit 1 | |
| fi | |
| done | |
| for forbidden in \ | |
| 'package-import-method=hardlink' \ | |
| 'lockfile-only' \ | |
| 'pnpm add pnpm@'; do | |
| if grep -Fq -- "$forbidden" "$builder" "$policy"; then | |
| echo "::error::forbidden pnpm builder contract fragment present: $forbidden" | |
| exit 1 | |
| fi | |
| done | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-pnpm-builder-contract" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| pnpm-regression: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: pnpm regression suite | |
| run: | | |
| bash genie/ci-scripts/nix-gc-race-retry.test.sh | |
| bash genie/ci-scripts/ci-measurement-comparison.test.sh | |
| bash genie/ci-scripts/native-dep-policy-audit.test.sh | |
| bash nix/workspace-tools/lib/mk-pnpm-cli/tests/run.sh --skip-genie --skip-megarepo --skip-devenv-shell --skip-downstream-megarepo | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-pnpm-regression" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| bundle-smoke: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Bundle smoke tests | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run bundle:smoke --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run bundle:smoke --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-bundle-smoke" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| cargo: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide Rust toolchain | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for out in $(nix build --no-link --print-out-paths nixpkgs#cargo nixpkgs#rustc nixpkgs#clippy nixpkgs#rustfmt); do | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| done | |
| - name: Cargo build + test + clippy + fmt | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd packages/@overeng/otelite | |
| cargo build --release | |
| cargo test | |
| cargo clippy -- -D warnings | |
| cargo fmt --check | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-cargo" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| devenv-perf: | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| permissions: | |
| actions: read | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| ARTIFACT_DIR: tmp/devenv-perf-ci | |
| OTEL_SERVICE_NAME: devenv-perf-ci | |
| RUNNER_CLASS: 'namespace-profile-linux-x86-64,namespace-features:github.run-id=${{ github.run_id }}' | |
| CI_MEASUREMENT_SUBJECT_REF: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.ref || github.ref }} | |
| CI_MEASUREMENT_SUBJECT_SHA: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.sha || github.sha }} | |
| CI_MEASUREMENT_SUBJECT_LABEL: ${{ inputs.measurement_baseline_label }} | |
| CI_MEASUREMENT_ALLOW_PROBE_FAILURES: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && '1' || '' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Benchmark devenv surfaces | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| ensure_ci_measurement_tool() { | |
| tool_name="$1" | |
| nix_attr="$2" | |
| if command -v "$tool_name" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if ! command -v nix >/dev/null 2>&1; then | |
| return 1 | |
| fi | |
| if tool_out="$(nix build --no-link --print-out-paths "nixpkgs#$nix_attr" 2>/dev/null)"; then | |
| while IFS= read -r tool_path; do | |
| [ -n "$tool_path" ] || continue | |
| [ -d "$tool_path/bin" ] || continue | |
| export PATH="$tool_path/bin:$PATH" | |
| if command -v "$tool_name" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| done <<EOF | |
| $tool_out | |
| EOF | |
| fi | |
| command -v "$tool_name" >/dev/null 2>&1 | |
| } | |
| require_ci_measurement_tool() { | |
| tool_name="$1" | |
| nix_attr="$2" | |
| if ensure_ci_measurement_tool "$tool_name" "$nix_attr"; then | |
| return 0 | |
| fi | |
| echo "::error::$tool_name is not available; unable to produce CI measurement artifact" | |
| exit 1 | |
| } | |
| require_ci_measurement_tool awk gawk.out | |
| require_ci_measurement_tool jq jq.bin | |
| ARTIFACT_DIR="$(mkdir -p "$ARTIFACT_DIR" && cd "$ARTIFACT_DIR" && pwd -P)" | |
| CI_MEASUREMENT_HEAD_DIR="${CI_MEASUREMENT_HEAD_DIR:-$PWD}" | |
| CI_MEASUREMENT_BASE_DIR="${CI_MEASUREMENT_BASE_DIR:-${RUNNER_TEMP:-/tmp}/ci-measurement-base}" | |
| CI_MEASUREMENT_PAIRED_ENABLED=0 | |
| CI_MEASUREMENT_ORDER_SEED="${CI_MEASUREMENT_ORDER_SEED:-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_SHA:-unknown}}" | |
| prepare_paired_base_worktree() { | |
| if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then | |
| return 0 | |
| fi | |
| if [ -n "${CI_MEASUREMENT_ALLOW_PROBE_FAILURES:-}" ]; then | |
| return 0 | |
| fi | |
| if [ ! -f "${GITHUB_EVENT_PATH:-}" ]; then | |
| return 0 | |
| fi | |
| local base_sha | |
| base_sha="$(jq -r '.pull_request.base.sha // empty' "$GITHUB_EVENT_PATH")" | |
| if [ -z "$base_sha" ]; then | |
| echo "::notice::paired wall-clock baseline unavailable: pull_request.base.sha missing" | |
| return 0 | |
| fi | |
| rm -rf "$CI_MEASUREMENT_BASE_DIR" | |
| git worktree prune >/dev/null 2>&1 || true | |
| if git fetch --no-tags --depth=1 origin "$base_sha" \ | |
| && git worktree add --detach "$CI_MEASUREMENT_BASE_DIR" "$base_sha" >/dev/null; then | |
| CI_MEASUREMENT_PAIRED_ENABLED=1 | |
| echo "::notice::paired wall-clock baseline prepared at $CI_MEASUREMENT_BASE_DIR ($base_sha)" | |
| else | |
| echo "::warning::paired wall-clock baseline unavailable: failed to prepare base worktree $base_sha" | |
| CI_MEASUREMENT_PAIRED_ENABLED=0 | |
| fi | |
| } | |
| prepare_paired_base_worktree | |
| mkdir -p "$ARTIFACT_DIR/traces" | |
| { | |
| printf 'timestamp_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| printf 'repository=%s\n' "${GITHUB_REPOSITORY:-unknown}" | |
| printf 'ref=%s\n' "${GITHUB_REF:-unknown}" | |
| printf 'sha=%s\n' "${GITHUB_SHA:-unknown}" | |
| printf 'runner_name=%s\n' "${RUNNER_NAME:-unknown}" | |
| printf 'runner_os=%s\n' "${RUNNER_OS:-unknown}" | |
| printf 'runner_arch=%s\n' "${RUNNER_ARCH:-unknown}" | |
| printf 'runner_class=%s\n' "${RUNNER_CLASS:-unknown}" | |
| printf 'cpu_count=%s\n' "$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || printf unknown)" | |
| printf 'load_average=%s\n' "$(cat /proc/loadavg 2>/dev/null || uptime 2>/dev/null || printf unknown)" | |
| printf 'memory=%s\n' "$(free -h 2>/dev/null | tr '\n' ';' || printf unknown)" | |
| printf 'cpu_model=%s\n' "$(awk -F ': ' '/model name|Hardware|Processor/ { print $2; exit }' /proc/cpuinfo 2>/dev/null || printf unknown)" | |
| printf 'devenv_rev=%s\n' "${DEVENV_REV:-unknown}" | |
| printf 'otel_service_name=%s\n' "${OTEL_SERVICE_NAME:-unknown}" | |
| nix store ping 2>/dev/null || true | |
| nix config show substituters trusted-substituters builders max-jobs cores 2>/dev/null || true | |
| df -h / /nix 2>/dev/null || df -h / | |
| ps -eo pid,ppid,stat,etime,pcpu,pmem,comm,args 2>/dev/null \ | |
| | grep -E 'devenv direnv-export|nix-daemon|nix build|nix flake|github-runner' \ | |
| | grep -v grep || true | |
| } >"$ARTIFACT_DIR/host-context.txt" | |
| printf '[' >"$ARTIFACT_DIR/timings.json" | |
| first=1 | |
| json_append_timing() { | |
| local id="$1" | |
| local label="$2" | |
| local group="$3" | |
| local description="$4" | |
| local status="$5" | |
| local duration_ms="$6" | |
| local stdout="$7" | |
| local stderr="$8" | |
| local trace="$9" | |
| local gate_policy="${10}" | |
| local metadata_json="${11}" | |
| local samples_file="$ARTIFACT_DIR/$id.samples.json" | |
| if [ "$first" -eq 0 ]; then | |
| printf ',' >>"$ARTIFACT_DIR/timings.json" | |
| fi | |
| first=0 | |
| jq -cn \ | |
| --slurpfile samples "$samples_file" \ | |
| --arg id "$id" \ | |
| --arg label "$label" \ | |
| --arg group "$group" \ | |
| --arg description "$description" \ | |
| --argjson status "$status" \ | |
| --argjson durationMs "$duration_ms" \ | |
| --arg stdout "$stdout" \ | |
| --arg stderr "$stderr" \ | |
| --arg trace "$trace" \ | |
| --argjson gatePolicy "$gate_policy" \ | |
| --argjson metadata "$metadata_json" \ | |
| 'def median: | |
| sort as $sorted | |
| | ($sorted | length) as $count | |
| | if $count == 0 then null | |
| elif ($count % 2) == 1 then $sorted[($count / 2 | floor)] | |
| else (($sorted[($count / 2 - 1)] + $sorted[($count / 2)]) / 2) | |
| end; | |
| def percentile($p): | |
| sort as $sorted | |
| | ($sorted | length) as $count | |
| | if $count == 0 then null | |
| else $sorted[(($p * ($count - 1)) | floor)] | |
| end; | |
| ($samples[0] // []) as $sampleList | |
| | ($sampleList | map(select((.subject // "head") == "head" and .phase != "warmup" and .status == 0) | .durationMs)) as $successfulDurations | |
| | ($sampleList | map(select((.subject // "head") == "head" and .phase == "warmup"))) as $warmupSamples | |
| | ($sampleList | map(select((.subject // "head") == "head" and .phase == "measured" and .status == 0 and .pairIndex != null))) as $headSamples | |
| | ($sampleList | map(select(.subject == "base" and .phase == "measured" and .status == 0 and .pairIndex != null))) as $baseSamples | |
| | ( | |
| $headSamples | |
| | map(. as $head | $baseSamples[]? | select(.pairIndex == $head.pairIndex) | { | |
| pairIndex: $head.pairIndex, | |
| currentDurationMs: $head.durationMs, | |
| baselineDurationMs: .durationMs, | |
| deltaMs: ($head.durationMs - .durationMs) | |
| }) | |
| ) as $pairedSamples | |
| | ($pairedSamples | map(.currentDurationMs)) as $pairedCurrentDurations | |
| | ($pairedSamples | map(.baselineDurationMs)) as $pairedBaselineDurations | |
| | ($pairedSamples | map(.deltaMs)) as $pairedDeltaDurations | |
| | ($pairedDeltaDurations | median) as $pairedDeltaMedian | |
| | { | |
| id:$id, | |
| name:$id, | |
| label:$label, | |
| group:(if $group == "" then null else $group end), | |
| description:(if $description == "" then null else $description end), | |
| status:$status, | |
| durationMs:$durationMs, | |
| stdout:$stdout, | |
| stderr:$stderr, | |
| trace:(if $trace == "" then null else $trace end), | |
| metadata:$metadata, | |
| gatePolicy:$gatePolicy, | |
| statistics: { | |
| sampleCount: ($sampleList | length), | |
| warmupCount: ($warmupSamples | length), | |
| measuredSampleCount: ( | |
| $sampleList | |
| | map(select((.subject // "head") == "head" and .phase != "warmup")) | |
| | length | |
| ), | |
| successfulSampleCount: ($successfulDurations | length), | |
| minDurationMs: ($successfulDurations | min), | |
| maxDurationMs: ($successfulDurations | max), | |
| medianDurationMs: $durationMs, | |
| pairedSampleCount: ($pairedSamples | length), | |
| pairedCurrentMedianDurationMs: ($pairedCurrentDurations | median), | |
| pairedBaselineMedianDurationMs: ($pairedBaselineDurations | median), | |
| pairedDeltaMedianDurationMs: $pairedDeltaMedian, | |
| pairedDeltaMinDurationMs: ($pairedDeltaDurations | min), | |
| pairedDeltaMaxDurationMs: ($pairedDeltaDurations | max), | |
| pairedDeltaP25DurationMs: ($pairedDeltaDurations | percentile(0.25)), | |
| pairedDeltaP75DurationMs: ($pairedDeltaDurations | percentile(0.75)), | |
| pairedDeltaMadDurationMs: ( | |
| if $pairedDeltaMedian == null then null | |
| else ($pairedDeltaDurations | map(. - $pairedDeltaMedian | if . < 0 then -. else . end) | median) | |
| end | |
| ), | |
| pairedDeltaSampleDurationMs: $pairedDeltaDurations | |
| }, | |
| samples:$sampleList | |
| }' \ | |
| >>"$ARTIFACT_DIR/timings.json" | |
| } | |
| measure() { | |
| local id="$1" | |
| local label="$2" | |
| local group="$3" | |
| local description="$4" | |
| local trace_file="$5" | |
| local warmup_repetitions="$6" | |
| local repetitions="$7" | |
| local gate_policy="$8" | |
| local metadata_json="$9" | |
| shift 9 | |
| case "$trace_file" in | |
| '$ARTIFACT_DIR'*) trace_file="${ARTIFACT_DIR}${trace_file#'$ARTIFACT_DIR'}" ;; | |
| esac | |
| local stdout="$ARTIFACT_DIR/$id.stdout" | |
| local stderr="$ARTIFACT_DIR/$id.stderr" | |
| local samples_file="$ARTIFACT_DIR/$id.samples.json" | |
| local started ended status duration_ms | |
| mkdir -p "$(dirname "$trace_file")" | |
| if ! [[ "$repetitions" =~ ^[0-9]+$ ]] || [ "$repetitions" -lt 1 ]; then | |
| repetitions=1 | |
| fi | |
| if ! [[ "$warmup_repetitions" =~ ^[0-9]+$ ]] || [ "$warmup_repetitions" -lt 0 ]; then | |
| warmup_repetitions=0 | |
| fi | |
| printf '[' >"$samples_file" | |
| local sample_first=1 | |
| local sample_index measured_index total_repetitions phase sample_stdout sample_stderr sample_trace expanded | |
| local order_offset | |
| order_offset="$(printf '%s' "$CI_MEASUREMENT_ORDER_SEED:$id" | cksum | awk '{ print $1 % 2 }')" | |
| total_repetitions=$((warmup_repetitions + repetitions)) | |
| for sample_index in $(seq 1 "$total_repetitions"); do | |
| if [ "$sample_index" -le "$warmup_repetitions" ]; then | |
| phase="warmup" | |
| measured_index="" | |
| else | |
| phase="measured" | |
| measured_index=$((sample_index - warmup_repetitions)) | |
| fi | |
| sample_stdout="$ARTIFACT_DIR/$id.$sample_index.stdout" | |
| sample_stderr="$ARTIFACT_DIR/$id.$sample_index.stderr" | |
| sample_trace="" | |
| if [ -n "$trace_file" ]; then | |
| sample_trace="$trace_file" | |
| if [ "$repetitions" -gt 1 ]; then | |
| sample_trace="${trace_file%.*}.$sample_index.${trace_file##*.}" | |
| fi | |
| fi | |
| expanded=() | |
| for arg in "$@"; do | |
| case "$arg" in | |
| '$DEVENV_BIN') expanded+=("${DEVENV_BIN:?DEVENV_BIN not set}") ;; | |
| '$DEVENV_SHELL_TRACE_COMMAND') | |
| if "${DEVENV_BIN:?DEVENV_BIN not set}" --help 2>&1 | grep -q -- '--trace-to'; then | |
| expanded+=("${DEVENV_BIN:?DEVENV_BIN not set}" "--trace-to" "json:file:$sample_trace" "shell" "--no-reload" "--" "true") | |
| elif "${DEVENV_BIN:?DEVENV_BIN not set}" --help 2>&1 | grep -q -- '--trace-format'; then | |
| expanded+=("${DEVENV_BIN:?DEVENV_BIN not set}" "--trace-format" "json" "shell" "--no-reload" "--" "true") | |
| sample_trace="" | |
| else | |
| expanded+=("${DEVENV_BIN:?DEVENV_BIN not set}" "shell" "--no-reload" "--" "true") | |
| sample_trace="" | |
| fi | |
| ;; | |
| '$ARTIFACT_DIR'*) expanded+=("${ARTIFACT_DIR}${arg#'$ARTIFACT_DIR'}") ;; | |
| 'json:file:$trace_file') expanded+=("json:file:$sample_trace") ;; | |
| '$trace_file') expanded+=("file:$sample_trace") ;; | |
| *) expanded+=("$arg") ;; | |
| esac | |
| done | |
| local base_ran_before_head=0 base_stdout base_stderr base_started base_ended base_status base_duration_ms | |
| if [ "$phase" = "measured" ] && [ "$CI_MEASUREMENT_PAIRED_ENABLED" -eq 1 ] && [ $(((measured_index + order_offset) % 2)) -eq 0 ]; then | |
| base_ran_before_head=1 | |
| base_stdout="$ARTIFACT_DIR/$id.$sample_index.base.stdout" | |
| base_stderr="$ARTIFACT_DIR/$id.$sample_index.base.stderr" | |
| base_started="$(date +%s%3N)" | |
| set +e | |
| (cd "$CI_MEASUREMENT_BASE_DIR" && "${expanded[@]}") >"$base_stdout" 2>"$base_stderr" | |
| base_status=$? | |
| set -e | |
| base_ended="$(date +%s%3N)" | |
| base_duration_ms=$((base_ended - base_started)) | |
| if [ "$sample_first" -eq 0 ]; then | |
| printf ',' >>"$samples_file" | |
| fi | |
| sample_first=0 | |
| jq -cn \ | |
| --argjson index "$sample_index" \ | |
| --arg measuredIndex "$measured_index" \ | |
| --argjson status "$base_status" \ | |
| --argjson durationMs "$base_duration_ms" \ | |
| --arg stdout "$base_stdout" \ | |
| --arg stderr "$base_stderr" \ | |
| --arg orderSeed "$CI_MEASUREMENT_ORDER_SEED" \ | |
| '{index:$index,measuredIndex:($measuredIndex | tonumber),pairIndex:($measuredIndex | tonumber),subject:"base",phase:"measured",status:$status,durationMs:$durationMs,stdout:$stdout,stderr:$stderr,trace:null,order:"base-head",orderSeed:$orderSeed}' \ | |
| >>"$samples_file" | |
| if [ "$base_status" -ne 0 ]; then | |
| echo "::warning::$id paired baseline sample $measured_index failed after ${base_duration_ms}ms; this pair is excluded from wall-clock gating" | |
| tail -40 "$base_stderr" || true | |
| fi | |
| fi | |
| started="$(date +%s%3N)" | |
| set +e | |
| (cd "$CI_MEASUREMENT_HEAD_DIR" && "${expanded[@]}") >"$sample_stdout" 2>"$sample_stderr" | |
| status=$? | |
| set -e | |
| ended="$(date +%s%3N)" | |
| duration_ms=$((ended - started)) | |
| if [ "$sample_first" -eq 0 ]; then | |
| printf ',' >>"$samples_file" | |
| fi | |
| sample_first=0 | |
| jq -cn \ | |
| --argjson index "$sample_index" \ | |
| --arg measuredIndex "$measured_index" \ | |
| --arg phase "$phase" \ | |
| --argjson status "$status" \ | |
| --argjson durationMs "$duration_ms" \ | |
| --arg stdout "$sample_stdout" \ | |
| --arg stderr "$sample_stderr" \ | |
| --arg trace "$sample_trace" \ | |
| --arg order "$(if [ "$phase" = "measured" ] && [ "$base_ran_before_head" -eq 1 ]; then printf base-head; else printf head-base; fi)" \ | |
| --arg orderSeed "$CI_MEASUREMENT_ORDER_SEED" \ | |
| '{index:$index,measuredIndex:(if $measuredIndex == "" then null else ($measuredIndex | tonumber) end),pairIndex:(if $measuredIndex == "" then null else ($measuredIndex | tonumber) end),subject:"head",phase:$phase,status:$status,durationMs:$durationMs,stdout:$stdout,stderr:$stderr,trace:(if $trace == "" then null else $trace end),order:(if $phase == "measured" then $order else null end),orderSeed:(if $phase == "measured" then $orderSeed else null end)}' \ | |
| >>"$samples_file" | |
| if [ "$phase" = "measured" ] && [ "$status" -eq 0 ] && [ "$CI_MEASUREMENT_PAIRED_ENABLED" -eq 1 ] && [ "$base_ran_before_head" -eq 0 ]; then | |
| base_stdout="$ARTIFACT_DIR/$id.$sample_index.base.stdout" | |
| base_stderr="$ARTIFACT_DIR/$id.$sample_index.base.stderr" | |
| base_started="$(date +%s%3N)" | |
| set +e | |
| (cd "$CI_MEASUREMENT_BASE_DIR" && "${expanded[@]}") >"$base_stdout" 2>"$base_stderr" | |
| base_status=$? | |
| set -e | |
| base_ended="$(date +%s%3N)" | |
| base_duration_ms=$((base_ended - base_started)) | |
| printf ',' >>"$samples_file" | |
| jq -cn \ | |
| --argjson index "$sample_index" \ | |
| --arg measuredIndex "$measured_index" \ | |
| --argjson status "$base_status" \ | |
| --argjson durationMs "$base_duration_ms" \ | |
| --arg stdout "$base_stdout" \ | |
| --arg stderr "$base_stderr" \ | |
| --arg orderSeed "$CI_MEASUREMENT_ORDER_SEED" \ | |
| '{index:$index,measuredIndex:($measuredIndex | tonumber),pairIndex:($measuredIndex | tonumber),subject:"base",phase:"measured",status:$status,durationMs:$durationMs,stdout:$stdout,stderr:$stderr,trace:null,order:"head-base",orderSeed:$orderSeed}' \ | |
| >>"$samples_file" | |
| if [ "$base_status" -ne 0 ]; then | |
| echo "::warning::$id paired baseline sample $measured_index failed after ${base_duration_ms}ms; this pair is excluded from wall-clock gating" | |
| tail -40 "$base_stderr" || true | |
| fi | |
| fi | |
| stdout="$sample_stdout" | |
| stderr="$sample_stderr" | |
| trace_file="$sample_trace" | |
| if [ "$status" -ne 0 ]; then | |
| break | |
| fi | |
| done | |
| printf ']\n' >>"$samples_file" | |
| status="$(jq -r 'map(select((.subject // "head") == "head") | .status) | max // 0' "$samples_file")" | |
| duration_ms="$(jq -r 'map(select((.subject // "head") == "head" and .phase != "warmup" and .status == 0) | .durationMs) as $values | if ($values | length) == 0 then (map(select((.subject // "head") == "head") | .durationMs) | max // 0) else ($values | sort | .[(length - 1) / 2 | floor]) end' "$samples_file")" | |
| cp "$stdout" "$ARTIFACT_DIR/$id.stdout" 2>/dev/null || true | |
| cp "$stderr" "$ARTIFACT_DIR/$id.stderr" 2>/dev/null || true | |
| json_append_timing "$id" "$label" "$group" "$description" "$status" "$duration_ms" "$ARTIFACT_DIR/$id.stdout" "$ARTIFACT_DIR/$id.stderr" "$trace_file" "$gate_policy" "$metadata_json" | |
| if [ "$status" -ne 0 ]; then | |
| if [ "${CI_MEASUREMENT_ALLOW_PROBE_FAILURES:-}" = "1" ]; then | |
| echo "::warning::$id failed after ${duration_ms}ms; keeping earlier successful baseline probes and excluding this failed probe from numeric observations" | |
| else | |
| echo "::error::$id failed after ${duration_ms}ms; stderr tail follows" | |
| fi | |
| tail -80 "$stderr" || true | |
| if [ "${CI_MEASUREMENT_ALLOW_PROBE_FAILURES:-}" != "1" ]; then | |
| return "$status" | |
| fi | |
| fi | |
| } | |
| measure 'shell_eval_traced' 'Shell eval with OTEL trace' 'devenv shell' 'Evaluates the dev shell with native devenv JSON tracing enabled.' '$ARTIFACT_DIR/traces/shell_eval_traced.json' '0' '1' '{"enabled":false,"minBaselineSources":10,"minCurrentSamples":3,"warnRatio":1.25,"failRatio":1.5,"warnAbs":1.5,"failAbs":3,"noiseFloor":0.5,"statisticalToleranceRatio":0.2,"statisticalToleranceAbs":1}' '{"path":[],"dimensions":{}}' '$DEVENV_SHELL_TRACE_COMMAND' | |
| measure 'shell_eval_warm' 'Warm shell eval' 'devenv shell' 'Evaluates a warm dev shell without reloading direnv state.' '' '1' '5' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":5,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.1,"failRatio":1.2,"warnAbs":0.25,"failAbs":1,"noiseFloor":0.1,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.25}' '{"path":[],"dimensions":{}}' '$DEVENV_BIN' 'shell' '--no-reload' '--' 'true' | |
| measure 'tasks_list' 'devenv tasks list' 'devenv cli' 'Lists devenv tasks to measure task graph loading overhead.' '' '1' '9' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":7,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.25,"failRatio":1.5,"warnAbs":0.05,"failAbs":0.15,"noiseFloor":0.03,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.03}' '{"path":[],"dimensions":{}}' '$DEVENV_BIN' 'tasks' 'list' | |
| measure 'processes_help' 'devenv processes --help' 'devenv cli' 'Loads the devenv processes command help path.' '' '1' '9' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":7,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.25,"failRatio":1.5,"warnAbs":0.05,"failAbs":0.15,"noiseFloor":0.03,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.03}' '{"path":[],"dimensions":{}}' '$DEVENV_BIN' 'processes' '--help' | |
| measure 'task_pnpm_install' 'pnpm install task' 'workspace setup' 'Runs the cached pnpm install devenv task.' '' '1' '5' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":5,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.1,"failRatio":1.2,"warnAbs":0.25,"failAbs":1,"noiseFloor":0.1,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.25}' '{"path":[],"dimensions":{}}' '$DEVENV_BIN' 'tasks' 'run' 'pnpm:install' '--mode' 'before' '--no-tui' '--show-output' | |
| measure 'task_genie_run' 'Genie run task' 'genie' 'Runs the normal devenv genie:run task including its declared dependencies.' '' '1' '5' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":5,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.1,"failRatio":1.2,"warnAbs":0.25,"failAbs":1,"noiseFloor":0.1,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.25}' '{"path":[],"dimensions":{}}' '$DEVENV_BIN' 'tasks' 'run' 'genie:run' '--mode' 'before' '--no-tui' '--show-output' | |
| measure 'task_check_quick_warm' 'Warm cached check:quick' 'quality gates' 'Runs the fast local quality gate through devenv after a warmup. This measures the cached no-op path and task/status orchestration overhead.' '' '1' '5' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":5,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.1,"failRatio":1.2,"warnAbs":0.25,"failAbs":1,"noiseFloor":0.1,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.25}' '{"path":["quality gates","check:quick"],"dimensions":{"workload":"cached-no-op","taskCacheMode":"warm"}}' '$DEVENV_BIN' 'tasks' 'run' 'check:quick' '--mode' 'before' '--no-tui' '--show-output' | |
| measure 'task_check_quick_forced' 'Forced check:quick' 'quality gates' 'Runs the fast local quality gate through devenv with task-cache refresh. This measures the developer-facing quick-check workload rather than the cached no-op path.' '' '0' '3' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":3,"minBaselineSources":10,"minCurrentSamples":3,"warnRatio":1.15,"failRatio":1.3,"warnAbs":1.5,"failAbs":4,"noiseFloor":0.75,"statisticalToleranceRatio":0.15,"statisticalToleranceAbs":1}' '{"path":["quality gates","check:quick"],"dimensions":{"workload":"forced-task-cache","taskCacheMode":"refresh"}}' '$DEVENV_BIN' 'tasks' 'run' 'check:quick' '--mode' 'before' '--no-tui' '--show-output' '--refresh-task-cache' | |
| measure 'genie_check_task' 'Genie check task' 'genie' 'Runs the supported Genie check task without shell-entry overhead.' '' '1' '5' '{"enabled":true,"comparisonMode":"paired","minPairedSamples":5,"minBaselineSources":10,"minCurrentSamples":5,"warnRatio":1.1,"failRatio":1.2,"warnAbs":0.25,"failAbs":1,"noiseFloor":0.1,"statisticalToleranceRatio":0.1,"statisticalToleranceAbs":0.25}' '{"path":[],"dimensions":{}}' '$DEVENV_BIN' 'tasks' 'run' 'genie:check' '--mode' 'before' '--no-tui' | |
| printf ']\n' >>"$ARTIFACT_DIR/timings.json" | |
| jq . "$ARTIFACT_DIR/timings.json" >"$ARTIFACT_DIR/timings.pretty.json" | |
| jq -n \ | |
| --slurpfile timings "$ARTIFACT_DIR/timings.json" \ | |
| --arg schemaVersion "1" \ | |
| --arg generatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --arg repository "${GITHUB_REPOSITORY:-unknown}" \ | |
| --arg ref "${GITHUB_REF:-unknown}" \ | |
| --arg sha "${GITHUB_SHA:-unknown}" \ | |
| --arg runnerName "${RUNNER_NAME:-unknown}" \ | |
| --arg runnerOs "${RUNNER_OS:-unknown}" \ | |
| --arg runnerArch "${RUNNER_ARCH:-unknown}" \ | |
| --arg devenvRev "${DEVENV_REV:-unknown}" \ | |
| --arg otelServiceName "${OTEL_SERVICE_NAME:-unknown}" \ | |
| '{ | |
| schemaVersion: $schemaVersion, | |
| generatedAt: $generatedAt, | |
| repository: $repository, | |
| ref: $ref, | |
| sha: $sha, | |
| runner: { name: $runnerName, os: $runnerOs, arch: $runnerArch }, | |
| devenv: { rev: $devenvRev }, | |
| otel: { serviceName: $otelServiceName }, | |
| checks: ($timings[0] | map({ key: .id, value: . }) | from_entries) | |
| }' >"$ARTIFACT_DIR/summary.json" | |
| jq -n \ | |
| --slurpfile timings "$ARTIFACT_DIR/timings.json" \ | |
| --argjson schemaVersion 1 \ | |
| --arg generatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --arg repository "${GITHUB_REPOSITORY:-unknown}" \ | |
| --arg branchKind "${GITHUB_EVENT_NAME:-unknown}" \ | |
| --arg ref "${CI_MEASUREMENT_SUBJECT_REF:-${GITHUB_REF:-unknown}}" \ | |
| --arg headSha "${CI_MEASUREMENT_SUBJECT_SHA:-${GITHUB_SHA:-unknown}}" \ | |
| --arg baseSha "${GITHUB_BASE_SHA:-}" \ | |
| --arg runnerName "${RUNNER_NAME:-unknown}" \ | |
| --arg runnerOs "${RUNNER_OS:-unknown}" \ | |
| --arg runnerArch "${RUNNER_ARCH:-unknown}" \ | |
| --arg runnerClass "${RUNNER_CLASS:-unknown}" \ | |
| --arg githubRunId "${GITHUB_RUN_ID:-unknown}" \ | |
| --arg githubRunAttempt "${GITHUB_RUN_ATTEMPT:-unknown}" \ | |
| --arg githubJob "${GITHUB_JOB:-unknown}" \ | |
| --arg taskId "${CROSSTASK_TASK_ID:-}" \ | |
| --arg taskAttemptId "${CROSSTASK_ATTEMPT_ID:-}" \ | |
| --arg traceId "${TRACE_ID:-}" \ | |
| --arg devenvRev "${DEVENV_REV:-unknown}" \ | |
| --arg otelServiceName "${OTEL_SERVICE_NAME:-unknown}" \ | |
| --arg orderSeed "$CI_MEASUREMENT_ORDER_SEED" \ | |
| --arg targetSystem "${DEVENV_SYSTEM:-${RUNNER_OS:-unknown}}" \ | |
| '{ | |
| schemaVersion: $schemaVersion, | |
| generatedAt: $generatedAt, | |
| producer: { | |
| name: "effect-utils-ci-measurement", | |
| version: 2, | |
| measurementProtocol: "devenv-perf-warm-median-v2" | |
| }, | |
| subject: { | |
| repo: $repository, | |
| branchKind: (if $branchKind == "" then "unknown" else $branchKind end), | |
| ref: $ref, | |
| headSha: $headSha, | |
| baseSha: $baseSha | |
| }, | |
| execution: { | |
| provider: (if ($githubRunId != "" and $githubRunId != "unknown") then "github-actions" else "local" end), | |
| workflow: "CI", | |
| job: $githubJob, | |
| runId: $githubRunId, | |
| runAttempt: $githubRunAttempt, | |
| taskId: $taskId, | |
| attemptId: $taskAttemptId, | |
| traceId: $traceId, | |
| runner: { name: $runnerName, os: $runnerOs, arch: $runnerArch, class: $runnerClass } | |
| }, | |
| target: { kind: "devenv", id: "dev-shell", name: "dev-shell", label: "Dev shell", group: "devenv", system: $targetSystem }, | |
| observations: ( | |
| $timings[0] | |
| | map(select(.status == 0)) | |
| | map({ | |
| id: ("devenv." + .id + ".duration"), | |
| label: .label, | |
| group: .group, | |
| path: (.metadata.path // []), | |
| description: .description, | |
| measurementKind: (if (.gatePolicy.enabled == false) then "diagnostic" else "wall-clock" end), | |
| name: ("devenv." + .id + ".duration"), | |
| unit: "seconds", | |
| value: (.durationMs / 1000), | |
| policy: .gatePolicy, | |
| comparison: { | |
| mode: (.gatePolicy.comparisonMode // "historical"), | |
| pairedSampleCount: (.statistics.pairedSampleCount // 0), | |
| baseline: ( | |
| if (.statistics.pairedBaselineMedianDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedBaselineMedianDurationMs / 1000) | |
| end | |
| ) | |
| }, | |
| statistics: { | |
| sampleCount: (.statistics.sampleCount // 1), | |
| warmupCount: (.statistics.warmupCount // 0), | |
| measuredSampleCount: (.statistics.measuredSampleCount // (.statistics.sampleCount // 1)), | |
| successfulSampleCount: (.statistics.successfulSampleCount // (if .status == 0 then 1 else 0 end)), | |
| min: ((.statistics.minDurationMs // .durationMs) / 1000), | |
| max: ((.statistics.maxDurationMs // .durationMs) / 1000), | |
| median: ((.statistics.medianDurationMs // .durationMs) / 1000), | |
| pairedSampleCount: (.statistics.pairedSampleCount // 0), | |
| pairedCurrentMedian: ( | |
| if (.statistics.pairedCurrentMedianDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedCurrentMedianDurationMs / 1000) | |
| end | |
| ), | |
| pairedBaselineMedian: ( | |
| if (.statistics.pairedBaselineMedianDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedBaselineMedianDurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaMedian: ( | |
| if (.statistics.pairedDeltaMedianDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedDeltaMedianDurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaMin: ( | |
| if (.statistics.pairedDeltaMinDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedDeltaMinDurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaMax: ( | |
| if (.statistics.pairedDeltaMaxDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedDeltaMaxDurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaP25: ( | |
| if (.statistics.pairedDeltaP25DurationMs // null) == null | |
| then null | |
| else (.statistics.pairedDeltaP25DurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaP75: ( | |
| if (.statistics.pairedDeltaP75DurationMs // null) == null | |
| then null | |
| else (.statistics.pairedDeltaP75DurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaMad: ( | |
| if (.statistics.pairedDeltaMadDurationMs // null) == null | |
| then null | |
| else (.statistics.pairedDeltaMadDurationMs / 1000) | |
| end | |
| ), | |
| pairedDeltaSamples: ((.statistics.pairedDeltaSampleDurationMs // []) | map(. / 1000)) | |
| }, | |
| dimensions: ((.metadata.dimensions // {}) + { | |
| probe: .id, | |
| probeLabel: .label, | |
| status: .status, | |
| sampleCount: (.statistics.sampleCount // 1), | |
| warmupCount: (.statistics.warmupCount // 0), | |
| measuredSampleCount: (.statistics.measuredSampleCount // (.statistics.sampleCount // 1)), | |
| pairedSampleCount: (.statistics.pairedSampleCount // 0), | |
| pairedOrderProtocol: ( | |
| if (.statistics.pairedSampleCount // 0) > 0 | |
| then "balanced-seeded-alternating-v1" | |
| else null | |
| end | |
| ), | |
| pairedOrderSeed: ( | |
| if (.statistics.pairedSampleCount // 0) > 0 | |
| then $orderSeed | |
| else null | |
| end | |
| ), | |
| measurementProtocol: "devenv-perf-warm-median-v2", | |
| aggregation: "median", | |
| phase: "warm", | |
| devenvRev: $devenvRev, | |
| otelServiceName: $otelServiceName | |
| }) | |
| }) | |
| ), | |
| artifacts: [ | |
| { name: "host-context", path: "host-context.txt", contentType: "text/plain" }, | |
| { name: "timings", path: "timings.json", contentType: "application/json" }, | |
| { name: "summary", path: "summary.json", contentType: "application/json" }, | |
| { name: "shell-eval-trace", path: "traces/shell_eval_traced.json", contentType: "application/json" } | |
| ], | |
| details: { | |
| stdoutStderrByProbe: ( | |
| $timings[0] | |
| | map({ key: .id, value: { label: .label, group: .group, description: .description, stdout: .stdout, stderr: .stderr, trace: .trace } }) | |
| | from_entries | |
| ) | |
| } | |
| }' >"$ARTIFACT_DIR/measurements.json" | |
| if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then | |
| { | |
| echo "### Devenv perf" | |
| echo "" | |
| echo "| Probe | Status | Duration |" | |
| echo "| --- | ---: | ---: |" | |
| jq -r '.[] | "| \(.label // .id) | \(.status) | \(.durationMs) ms |"' "$ARTIFACT_DIR/timings.json" | |
| echo "" | |
| echo "- Artifact directory: \`$ARTIFACT_DIR\`" | |
| echo "- OTEL service: \`${OTEL_SERVICE_NAME:-unknown}\`" | |
| } >>"$GITHUB_STEP_SUMMARY" | |
| fi | |
| cat "$ARTIFACT_DIR/timings.pretty.json" | |
| - name: Upload devenv perf artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: devenv-perf | |
| path: | | |
| tmp/devenv-perf-ci | |
| !tmp/devenv-perf-ci/baseline/** | |
| if-no-files-found: error | |
| retention-days: 7 | |
| timeout-minutes: 30 | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-devenv-perf" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| nix-closure-sizes: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| permissions: | |
| actions: read | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| env: | |
| CI_MEASUREMENT_SUBJECT_REF: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.ref || github.ref }} | |
| CI_MEASUREMENT_SUBJECT_SHA: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.sha || github.sha }} | |
| CI_MEASUREMENT_SUBJECT_LABEL: ${{ inputs.measurement_baseline_label }} | |
| CI_MEASUREMENT_ALLOW_PROBE_FAILURES: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && '1' || '' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: 'Measure Nix closure: genie' | |
| shell: bash | |
| env: | |
| ARTIFACT_DIR: tmp/nix-closure-ci/current/genie_package | |
| RUNNER_CLASS: '${{ runner.os }}-${{ runner.arch }}' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$ARTIFACT_DIR" | |
| installable='.#genie' | |
| target_id='genie_package' | |
| target_name='genie' | |
| target_label='Genie package' | |
| target_group='packages' | |
| target_description='the packaged Genie CLI closure' | |
| artifact_file="$ARTIFACT_DIR/measurements.json" | |
| target_system='x86_64-linux' | |
| out_path="$(nix build --no-update-lock-file --no-link --print-out-paths "$installable")" | |
| path_info="$ARTIFACT_DIR/nix-closure-path-info.json" | |
| paths_file="$ARTIFACT_DIR/nix-closure-paths.json" | |
| nix path-info --recursive --closure-size --json "$out_path" >"$path_info" | |
| jq 'to_entries | map({ path: .key, narSize: (.value.narSize // 0), closureSize: (.value.closureSize // 0) })' "$path_info" >"$paths_file" | |
| jq -n \ | |
| --slurpfile paths "$paths_file" \ | |
| --argjson schemaVersion 1 \ | |
| --arg generatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --arg repository "${GITHUB_REPOSITORY:-unknown}" \ | |
| --arg branchKind "${GITHUB_EVENT_NAME:-unknown}" \ | |
| --arg ref "${CI_MEASUREMENT_SUBJECT_REF:-${GITHUB_REF:-unknown}}" \ | |
| --arg headSha "${CI_MEASUREMENT_SUBJECT_SHA:-${GITHUB_SHA:-unknown}}" \ | |
| --arg baseSha "${GITHUB_BASE_SHA:-}" \ | |
| --arg runnerName "${RUNNER_NAME:-unknown}" \ | |
| --arg runnerOs "${RUNNER_OS:-unknown}" \ | |
| --arg runnerArch "${RUNNER_ARCH:-unknown}" \ | |
| --arg runnerClass "${RUNNER_CLASS:-unknown}" \ | |
| --arg githubRunId "${GITHUB_RUN_ID:-unknown}" \ | |
| --arg githubRunAttempt "${GITHUB_RUN_ATTEMPT:-unknown}" \ | |
| --arg githubJob "${GITHUB_JOB:-unknown}" \ | |
| --arg taskId "${CROSSTASK_TASK_ID:-}" \ | |
| --arg taskAttemptId "${CROSSTASK_ATTEMPT_ID:-}" \ | |
| --arg traceId "${TRACE_ID:-}" \ | |
| --arg targetName "$target_name" \ | |
| --arg targetId "$target_id" \ | |
| --arg targetLabel "$target_label" \ | |
| --arg targetGroup "$target_group" \ | |
| --arg targetDescription "$target_description" \ | |
| --arg targetSystem "$target_system" \ | |
| --arg outPath "$out_path" \ | |
| --argjson buckets '[{"name":"node","label":"Node / pnpm","pathRegex":"node_modules|npm-deps|pnpm"},{"name":"nix-sources","label":"Nix sources","pathRegex":"-source$"},{"name":"rust","label":"Rust","pathRegex":"cargo|rust|rustc"}]' \ | |
| --argjson targetPath '["nix","closures","packages","genie"]' \ | |
| --argjson gatePolicy '{}' \ | |
| ' | |
| ($paths[0] // []) as $closurePaths | |
| | ($closurePaths | map(select(.path == $outPath) | .closureSize) | first // ($closurePaths | map(.narSize) | add // 0)) as $totalClosureSize | |
| | ($closurePaths | map(.narSize) | add // 0) as $totalNarSize | |
| | ($closurePaths | length) as $pathCount | |
| | ($buckets | map( | |
| . as $bucket | |
| | { | |
| name: "nix.closure.bucket.nar_size", | |
| id: "nix.closure.bucket.nar_size", | |
| label: (($bucket.label // $bucket.name) + " closure size"), | |
| group: "nix closure buckets", | |
| path: ($targetPath + ["buckets", $bucket.name]), | |
| description: ("Store size contributed by closure paths matching " + $bucket.pathRegex), | |
| measurementKind: "deterministic", | |
| unit: "bytes", | |
| value: ( | |
| $closurePaths | |
| | map(select(.path | test($bucket.pathRegex)) | .narSize) | |
| | add // 0 | |
| ), | |
| policy: $gatePolicy, | |
| dimensions: { bucket: $bucket.name } | |
| } | |
| )) as $bucketObservations | |
| | { | |
| schemaVersion: $schemaVersion, | |
| generatedAt: $generatedAt, | |
| producer: { name: "effect-utils-ci-measurement", version: 1 }, | |
| subject: { | |
| repo: $repository, | |
| branchKind: (if $branchKind == "" then "unknown" else $branchKind end), | |
| ref: $ref, | |
| headSha: $headSha, | |
| baseSha: $baseSha | |
| }, | |
| execution: { | |
| provider: (if ($githubRunId != "" and $githubRunId != "unknown") then "github-actions" else "local" end), | |
| workflow: "CI", | |
| job: $githubJob, | |
| runId: $githubRunId, | |
| runAttempt: $githubRunAttempt, | |
| taskId: $taskId, | |
| attemptId: $taskAttemptId, | |
| traceId: $traceId, | |
| runner: { name: $runnerName, os: $runnerOs, arch: $runnerArch, class: $runnerClass } | |
| }, | |
| target: { kind: "nix-closure", id: $targetId, name: $targetName, label: $targetLabel, group: $targetGroup, path: $targetPath, system: $targetSystem }, | |
| observations: ([ | |
| { | |
| id: "nix.closure.nar_size", | |
| label: "Total closure size", | |
| group: "nix closure", | |
| path: ($targetPath + ["total", "closure-size"]), | |
| description: ("Total recursive store closure size for " + $targetDescription), | |
| name: "nix.closure.nar_size", | |
| measurementKind: "deterministic", | |
| unit: "bytes", | |
| value: $totalClosureSize, | |
| policy: $gatePolicy, | |
| dimensions: { bucket: "total" } | |
| }, | |
| { | |
| id: "nix.closure.serialized_nar_size", | |
| label: "Total serialized NAR size", | |
| group: "nix closure diagnostics", | |
| path: ($targetPath + ["total", "serialized-nar-size"]), | |
| description: ("Diagnostic sum of serialized NAR sizes for all paths in " + $targetDescription), | |
| name: "nix.closure.serialized_nar_size", | |
| measurementKind: "diagnostic", | |
| unit: "bytes", | |
| value: $totalNarSize, | |
| policy: { comparisonMode: "diagnostic", gate: "off" }, | |
| dimensions: { bucket: "total", sizeKind: "nar" } | |
| }, | |
| { | |
| id: "nix.closure.path_count", | |
| label: "Total closure path count", | |
| group: "nix closure", | |
| path: ($targetPath + ["total", "path-count"]), | |
| description: ("Number of store paths in " + $targetDescription), | |
| name: "nix.closure.path_count", | |
| measurementKind: "deterministic", | |
| unit: "count", | |
| value: $pathCount, | |
| policy: $gatePolicy, | |
| dimensions: { bucket: "total" } | |
| } | |
| ] + $bucketObservations), | |
| artifacts: [ | |
| { name: "nix-closure-path-info", path: "nix-closure-path-info.json", contentType: "application/json" }, | |
| { name: "nix-closure-paths", path: "nix-closure-paths.json", contentType: "application/json" } | |
| ], | |
| details: { | |
| outPath: $outPath, | |
| topPaths: ($closurePaths | sort_by(.narSize) | reverse | .[:30]) | |
| } | |
| } | |
| ' >"$artifact_file" | |
| cat "$artifact_file" | |
| - name: 'Measure Nix closure: megarepo' | |
| shell: bash | |
| env: | |
| ARTIFACT_DIR: tmp/nix-closure-ci/current/megarepo_package | |
| RUNNER_CLASS: '${{ runner.os }}-${{ runner.arch }}' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$ARTIFACT_DIR" | |
| installable='.#megarepo' | |
| target_id='megarepo_package' | |
| target_name='megarepo' | |
| target_label='Megarepo package' | |
| target_group='packages' | |
| target_description='the packaged megarepo CLI closure' | |
| artifact_file="$ARTIFACT_DIR/measurements.json" | |
| target_system='x86_64-linux' | |
| out_path="$(nix build --no-update-lock-file --no-link --print-out-paths "$installable")" | |
| path_info="$ARTIFACT_DIR/nix-closure-path-info.json" | |
| paths_file="$ARTIFACT_DIR/nix-closure-paths.json" | |
| nix path-info --recursive --closure-size --json "$out_path" >"$path_info" | |
| jq 'to_entries | map({ path: .key, narSize: (.value.narSize // 0), closureSize: (.value.closureSize // 0) })' "$path_info" >"$paths_file" | |
| jq -n \ | |
| --slurpfile paths "$paths_file" \ | |
| --argjson schemaVersion 1 \ | |
| --arg generatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --arg repository "${GITHUB_REPOSITORY:-unknown}" \ | |
| --arg branchKind "${GITHUB_EVENT_NAME:-unknown}" \ | |
| --arg ref "${CI_MEASUREMENT_SUBJECT_REF:-${GITHUB_REF:-unknown}}" \ | |
| --arg headSha "${CI_MEASUREMENT_SUBJECT_SHA:-${GITHUB_SHA:-unknown}}" \ | |
| --arg baseSha "${GITHUB_BASE_SHA:-}" \ | |
| --arg runnerName "${RUNNER_NAME:-unknown}" \ | |
| --arg runnerOs "${RUNNER_OS:-unknown}" \ | |
| --arg runnerArch "${RUNNER_ARCH:-unknown}" \ | |
| --arg runnerClass "${RUNNER_CLASS:-unknown}" \ | |
| --arg githubRunId "${GITHUB_RUN_ID:-unknown}" \ | |
| --arg githubRunAttempt "${GITHUB_RUN_ATTEMPT:-unknown}" \ | |
| --arg githubJob "${GITHUB_JOB:-unknown}" \ | |
| --arg taskId "${CROSSTASK_TASK_ID:-}" \ | |
| --arg taskAttemptId "${CROSSTASK_ATTEMPT_ID:-}" \ | |
| --arg traceId "${TRACE_ID:-}" \ | |
| --arg targetName "$target_name" \ | |
| --arg targetId "$target_id" \ | |
| --arg targetLabel "$target_label" \ | |
| --arg targetGroup "$target_group" \ | |
| --arg targetDescription "$target_description" \ | |
| --arg targetSystem "$target_system" \ | |
| --arg outPath "$out_path" \ | |
| --argjson buckets '[{"name":"node","label":"Node / pnpm","pathRegex":"node_modules|npm-deps|pnpm"},{"name":"nix-sources","label":"Nix sources","pathRegex":"-source$"},{"name":"rust","label":"Rust","pathRegex":"cargo|rust|rustc"}]' \ | |
| --argjson targetPath '["nix","closures","packages","megarepo"]' \ | |
| --argjson gatePolicy '{}' \ | |
| ' | |
| ($paths[0] // []) as $closurePaths | |
| | ($closurePaths | map(select(.path == $outPath) | .closureSize) | first // ($closurePaths | map(.narSize) | add // 0)) as $totalClosureSize | |
| | ($closurePaths | map(.narSize) | add // 0) as $totalNarSize | |
| | ($closurePaths | length) as $pathCount | |
| | ($buckets | map( | |
| . as $bucket | |
| | { | |
| name: "nix.closure.bucket.nar_size", | |
| id: "nix.closure.bucket.nar_size", | |
| label: (($bucket.label // $bucket.name) + " closure size"), | |
| group: "nix closure buckets", | |
| path: ($targetPath + ["buckets", $bucket.name]), | |
| description: ("Store size contributed by closure paths matching " + $bucket.pathRegex), | |
| measurementKind: "deterministic", | |
| unit: "bytes", | |
| value: ( | |
| $closurePaths | |
| | map(select(.path | test($bucket.pathRegex)) | .narSize) | |
| | add // 0 | |
| ), | |
| policy: $gatePolicy, | |
| dimensions: { bucket: $bucket.name } | |
| } | |
| )) as $bucketObservations | |
| | { | |
| schemaVersion: $schemaVersion, | |
| generatedAt: $generatedAt, | |
| producer: { name: "effect-utils-ci-measurement", version: 1 }, | |
| subject: { | |
| repo: $repository, | |
| branchKind: (if $branchKind == "" then "unknown" else $branchKind end), | |
| ref: $ref, | |
| headSha: $headSha, | |
| baseSha: $baseSha | |
| }, | |
| execution: { | |
| provider: (if ($githubRunId != "" and $githubRunId != "unknown") then "github-actions" else "local" end), | |
| workflow: "CI", | |
| job: $githubJob, | |
| runId: $githubRunId, | |
| runAttempt: $githubRunAttempt, | |
| taskId: $taskId, | |
| attemptId: $taskAttemptId, | |
| traceId: $traceId, | |
| runner: { name: $runnerName, os: $runnerOs, arch: $runnerArch, class: $runnerClass } | |
| }, | |
| target: { kind: "nix-closure", id: $targetId, name: $targetName, label: $targetLabel, group: $targetGroup, path: $targetPath, system: $targetSystem }, | |
| observations: ([ | |
| { | |
| id: "nix.closure.nar_size", | |
| label: "Total closure size", | |
| group: "nix closure", | |
| path: ($targetPath + ["total", "closure-size"]), | |
| description: ("Total recursive store closure size for " + $targetDescription), | |
| name: "nix.closure.nar_size", | |
| measurementKind: "deterministic", | |
| unit: "bytes", | |
| value: $totalClosureSize, | |
| policy: $gatePolicy, | |
| dimensions: { bucket: "total" } | |
| }, | |
| { | |
| id: "nix.closure.serialized_nar_size", | |
| label: "Total serialized NAR size", | |
| group: "nix closure diagnostics", | |
| path: ($targetPath + ["total", "serialized-nar-size"]), | |
| description: ("Diagnostic sum of serialized NAR sizes for all paths in " + $targetDescription), | |
| name: "nix.closure.serialized_nar_size", | |
| measurementKind: "diagnostic", | |
| unit: "bytes", | |
| value: $totalNarSize, | |
| policy: { comparisonMode: "diagnostic", gate: "off" }, | |
| dimensions: { bucket: "total", sizeKind: "nar" } | |
| }, | |
| { | |
| id: "nix.closure.path_count", | |
| label: "Total closure path count", | |
| group: "nix closure", | |
| path: ($targetPath + ["total", "path-count"]), | |
| description: ("Number of store paths in " + $targetDescription), | |
| name: "nix.closure.path_count", | |
| measurementKind: "deterministic", | |
| unit: "count", | |
| value: $pathCount, | |
| policy: $gatePolicy, | |
| dimensions: { bucket: "total" } | |
| } | |
| ] + $bucketObservations), | |
| artifacts: [ | |
| { name: "nix-closure-path-info", path: "nix-closure-path-info.json", contentType: "application/json" }, | |
| { name: "nix-closure-paths", path: "nix-closure-paths.json", contentType: "application/json" } | |
| ], | |
| details: { | |
| outPath: $outPath, | |
| topPaths: ($closurePaths | sort_by(.narSize) | reverse | .[:30]) | |
| } | |
| } | |
| ' >"$artifact_file" | |
| cat "$artifact_file" | |
| - name: 'Measure Nix closure: oxlint-npm' | |
| shell: bash | |
| env: | |
| ARTIFACT_DIR: tmp/nix-closure-ci/current/oxlint_npm_package | |
| RUNNER_CLASS: '${{ runner.os }}-${{ runner.arch }}' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$ARTIFACT_DIR" | |
| installable='.#oxlint-npm' | |
| target_id='oxlint_npm_package' | |
| target_name='oxlint-npm' | |
| target_label='oxlint npm package' | |
| target_group='packages' | |
| target_description='the packaged oxlint npm compatibility wrapper closure' | |
| artifact_file="$ARTIFACT_DIR/measurements.json" | |
| target_system='x86_64-linux' | |
| out_path="$(nix build --no-update-lock-file --no-link --print-out-paths "$installable")" | |
| path_info="$ARTIFACT_DIR/nix-closure-path-info.json" | |
| paths_file="$ARTIFACT_DIR/nix-closure-paths.json" | |
| nix path-info --recursive --closure-size --json "$out_path" >"$path_info" | |
| jq 'to_entries | map({ path: .key, narSize: (.value.narSize // 0), closureSize: (.value.closureSize // 0) })' "$path_info" >"$paths_file" | |
| jq -n \ | |
| --slurpfile paths "$paths_file" \ | |
| --argjson schemaVersion 1 \ | |
| --arg generatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| --arg repository "${GITHUB_REPOSITORY:-unknown}" \ | |
| --arg branchKind "${GITHUB_EVENT_NAME:-unknown}" \ | |
| --arg ref "${CI_MEASUREMENT_SUBJECT_REF:-${GITHUB_REF:-unknown}}" \ | |
| --arg headSha "${CI_MEASUREMENT_SUBJECT_SHA:-${GITHUB_SHA:-unknown}}" \ | |
| --arg baseSha "${GITHUB_BASE_SHA:-}" \ | |
| --arg runnerName "${RUNNER_NAME:-unknown}" \ | |
| --arg runnerOs "${RUNNER_OS:-unknown}" \ | |
| --arg runnerArch "${RUNNER_ARCH:-unknown}" \ | |
| --arg runnerClass "${RUNNER_CLASS:-unknown}" \ | |
| --arg githubRunId "${GITHUB_RUN_ID:-unknown}" \ | |
| --arg githubRunAttempt "${GITHUB_RUN_ATTEMPT:-unknown}" \ | |
| --arg githubJob "${GITHUB_JOB:-unknown}" \ | |
| --arg taskId "${CROSSTASK_TASK_ID:-}" \ | |
| --arg taskAttemptId "${CROSSTASK_ATTEMPT_ID:-}" \ | |
| --arg traceId "${TRACE_ID:-}" \ | |
| --arg targetName "$target_name" \ | |
| --arg targetId "$target_id" \ | |
| --arg targetLabel "$target_label" \ | |
| --arg targetGroup "$target_group" \ | |
| --arg targetDescription "$target_description" \ | |
| --arg targetSystem "$target_system" \ | |
| --arg outPath "$out_path" \ | |
| --argjson buckets '[{"name":"node","label":"Node / pnpm","pathRegex":"node_modules|npm-deps|pnpm"},{"name":"nix-sources","label":"Nix sources","pathRegex":"-source$"},{"name":"rust","label":"Rust","pathRegex":"cargo|rust|rustc"}]' \ | |
| --argjson targetPath '["nix","closures","packages","oxlint-npm"]' \ | |
| --argjson gatePolicy '{}' \ | |
| ' | |
| ($paths[0] // []) as $closurePaths | |
| | ($closurePaths | map(select(.path == $outPath) | .closureSize) | first // ($closurePaths | map(.narSize) | add // 0)) as $totalClosureSize | |
| | ($closurePaths | map(.narSize) | add // 0) as $totalNarSize | |
| | ($closurePaths | length) as $pathCount | |
| | ($buckets | map( | |
| . as $bucket | |
| | { | |
| name: "nix.closure.bucket.nar_size", | |
| id: "nix.closure.bucket.nar_size", | |
| label: (($bucket.label // $bucket.name) + " closure size"), | |
| group: "nix closure buckets", | |
| path: ($targetPath + ["buckets", $bucket.name]), | |
| description: ("Store size contributed by closure paths matching " + $bucket.pathRegex), | |
| measurementKind: "deterministic", | |
| unit: "bytes", | |
| value: ( | |
| $closurePaths | |
| | map(select(.path | test($bucket.pathRegex)) | .narSize) | |
| | add // 0 | |
| ), | |
| policy: $gatePolicy, | |
| dimensions: { bucket: $bucket.name } | |
| } | |
| )) as $bucketObservations | |
| | { | |
| schemaVersion: $schemaVersion, | |
| generatedAt: $generatedAt, | |
| producer: { name: "effect-utils-ci-measurement", version: 1 }, | |
| subject: { | |
| repo: $repository, | |
| branchKind: (if $branchKind == "" then "unknown" else $branchKind end), | |
| ref: $ref, | |
| headSha: $headSha, | |
| baseSha: $baseSha | |
| }, | |
| execution: { | |
| provider: (if ($githubRunId != "" and $githubRunId != "unknown") then "github-actions" else "local" end), | |
| workflow: "CI", | |
| job: $githubJob, | |
| runId: $githubRunId, | |
| runAttempt: $githubRunAttempt, | |
| taskId: $taskId, | |
| attemptId: $taskAttemptId, | |
| traceId: $traceId, | |
| runner: { name: $runnerName, os: $runnerOs, arch: $runnerArch, class: $runnerClass } | |
| }, | |
| target: { kind: "nix-closure", id: $targetId, name: $targetName, label: $targetLabel, group: $targetGroup, path: $targetPath, system: $targetSystem }, | |
| observations: ([ | |
| { | |
| id: "nix.closure.nar_size", | |
| label: "Total closure size", | |
| group: "nix closure", | |
| path: ($targetPath + ["total", "closure-size"]), | |
| description: ("Total recursive store closure size for " + $targetDescription), | |
| name: "nix.closure.nar_size", | |
| measurementKind: "deterministic", | |
| unit: "bytes", | |
| value: $totalClosureSize, | |
| policy: $gatePolicy, | |
| dimensions: { bucket: "total" } | |
| }, | |
| { | |
| id: "nix.closure.serialized_nar_size", | |
| label: "Total serialized NAR size", | |
| group: "nix closure diagnostics", | |
| path: ($targetPath + ["total", "serialized-nar-size"]), | |
| description: ("Diagnostic sum of serialized NAR sizes for all paths in " + $targetDescription), | |
| name: "nix.closure.serialized_nar_size", | |
| measurementKind: "diagnostic", | |
| unit: "bytes", | |
| value: $totalNarSize, | |
| policy: { comparisonMode: "diagnostic", gate: "off" }, | |
| dimensions: { bucket: "total", sizeKind: "nar" } | |
| }, | |
| { | |
| id: "nix.closure.path_count", | |
| label: "Total closure path count", | |
| group: "nix closure", | |
| path: ($targetPath + ["total", "path-count"]), | |
| description: ("Number of store paths in " + $targetDescription), | |
| name: "nix.closure.path_count", | |
| measurementKind: "deterministic", | |
| unit: "count", | |
| value: $pathCount, | |
| policy: $gatePolicy, | |
| dimensions: { bucket: "total" } | |
| } | |
| ] + $bucketObservations), | |
| artifacts: [ | |
| { name: "nix-closure-path-info", path: "nix-closure-path-info.json", contentType: "application/json" }, | |
| { name: "nix-closure-paths", path: "nix-closure-paths.json", contentType: "application/json" } | |
| ], | |
| details: { | |
| outPath: $outPath, | |
| topPaths: ($closurePaths | sort_by(.narSize) | reverse | .[:30]) | |
| } | |
| } | |
| ' >"$artifact_file" | |
| cat "$artifact_file" | |
| - name: 'Upload CI measurements: nix-closure-measurements' | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: nix-closure-measurements | |
| path: | | |
| tmp/nix-closure-ci | |
| !tmp/nix-closure-ci/baseline/** | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-nix-closure-sizes" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| source-shape: | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| permissions: | |
| actions: read | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| env: | |
| CI_MEASUREMENT_SUBJECT_REF: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.ref || github.ref }} | |
| CI_MEASUREMENT_SUBJECT_SHA: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.sha || github.sha }} | |
| CI_MEASUREMENT_SUBJECT_LABEL: ${{ inputs.measurement_baseline_label }} | |
| CI_MEASUREMENT_ALLOW_PROBE_FAILURES: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && '1' || '' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: 'Measure source shape: effect-utils' | |
| shell: bash | |
| env: | |
| ARTIFACT_DIR: tmp/source-shape-ci/current/effect-utils | |
| RUNNER_CLASS: '${{ runner.os }}-${{ runner.arch }}' | |
| run: | | |
| set -euo pipefail | |
| ensure_ci_measurement_tool() { | |
| tool_name="$1" | |
| nix_attr="$2" | |
| if command -v "$tool_name" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if ! command -v nix >/dev/null 2>&1; then | |
| return 1 | |
| fi | |
| if tool_out="$(nix build --no-link --print-out-paths "nixpkgs#$nix_attr" 2>/dev/null)"; then | |
| while IFS= read -r tool_path; do | |
| [ -n "$tool_path" ] || continue | |
| [ -d "$tool_path/bin" ] || continue | |
| export PATH="$tool_path/bin:$PATH" | |
| if command -v "$tool_name" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| done <<EOF | |
| $tool_out | |
| EOF | |
| fi | |
| command -v "$tool_name" >/dev/null 2>&1 | |
| } | |
| require_ci_measurement_tool() { | |
| tool_name="$1" | |
| nix_attr="$2" | |
| if ensure_ci_measurement_tool "$tool_name" "$nix_attr"; then | |
| return 0 | |
| fi | |
| echo "::error::$tool_name is not available; unable to produce CI measurement artifact" | |
| exit 1 | |
| } | |
| require_ci_measurement_tool node nodejs | |
| mkdir -p "$ARTIFACT_DIR" | |
| target_id='effect_utils' | |
| target_name='effect-utils' | |
| target_label='effect-utils repository' | |
| target_group='source' | |
| artifact_file="$ARTIFACT_DIR/measurements.json" | |
| target_system="${DEVENV_SYSTEM:-${RUNNER_OS:-unknown}}" | |
| SCOPES_JSON='[{"id":"genie_ci_workflow","label":"Genie CI workflow helpers","group":"source / ci","path":["source","effect-utils","genie","ci-workflow"],"includePaths":["genie/ci-workflow",".github/workflows/ci.yml.genie.ts"],"includeExtensions":[".ts"]},{"id":"genie_runtime","label":"Genie runtime","group":"source / genie","path":["source","effect-utils","packages","genie"],"includePaths":["packages/@overeng/genie/src"],"includeExtensions":[".ts",".tsx"]},{"id":"nix_workspace_tools","label":"Nix workspace tools","group":"source / nix","path":["source","effect-utils","nix","workspace-tools"],"includePaths":["nix/workspace-tools"],"includeExtensions":[".nix"]}]' \ | |
| TARGET_PATH_JSON='["source","effect-utils"]' \ | |
| TARGET_ID="$target_id" \ | |
| TARGET_NAME="$target_name" \ | |
| TARGET_LABEL="$target_label" \ | |
| TARGET_GROUP="$target_group" \ | |
| TARGET_SYSTEM="$target_system" \ | |
| node <<'NODE' >"$artifact_file" | |
| const cp = require('node:child_process') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const normalize = (value) => { | |
| const normalized = value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') | |
| return normalized === '.' ? '' : normalized | |
| } | |
| const scopes = JSON.parse(process.env.SCOPES_JSON || '[]') | |
| const targetPath = JSON.parse(process.env.TARGET_PATH_JSON || '["source"]') | |
| const gitFiles = cp | |
| .execFileSync('git', ['ls-files', '-z'], { encoding: 'buffer' }) | |
| .toString('utf8') | |
| .split('\0') | |
| .filter(Boolean) | |
| .map(normalize) | |
| const includesPath = (file, candidates) => { | |
| if (!Array.isArray(candidates) || candidates.length === 0) return true | |
| return candidates.map(normalize).some((candidate) => candidate === '' || file === candidate || file.startsWith(candidate + '/')) | |
| } | |
| const excludesPath = (file, candidates) => | |
| Array.isArray(candidates) && | |
| candidates.map(normalize).some((candidate) => candidate !== '' && (file === candidate || file.startsWith(candidate + '/'))) | |
| const matchesExtension = (file, extensions) => { | |
| if (!Array.isArray(extensions) || extensions.length === 0) return true | |
| const ext = path.extname(file).toLowerCase() | |
| return extensions.map((extension) => extension.toLowerCase()).some((extension) => ext === extension) | |
| } | |
| const countLines = (file) => { | |
| const buffer = fs.readFileSync(file) | |
| if (buffer.includes(0)) return undefined | |
| if (buffer.length === 0) return 0 | |
| let lines = 0 | |
| for (const byte of buffer) { | |
| if (byte === 10) lines += 1 | |
| } | |
| return buffer[buffer.length - 1] === 10 ? lines : lines + 1 | |
| } | |
| const observations = [] | |
| const scopeSummaries = [] | |
| for (const scope of scopes) { | |
| const root = normalize(scope.root || '.') | |
| const includePaths = Array.isArray(scope.includePaths) && scope.includePaths.length > 0 ? scope.includePaths : [root] | |
| const files = gitFiles | |
| .filter((file) => includesPath(file, includePaths)) | |
| .filter((file) => !excludesPath(file, scope.excludePaths)) | |
| .filter((file) => matchesExtension(file, scope.includeExtensions)) | |
| let lineCount = 0 | |
| let measuredFileCount = 0 | |
| for (const file of files) { | |
| const lines = countLines(file) | |
| if (lines === undefined) continue | |
| lineCount += lines | |
| measuredFileCount += 1 | |
| } | |
| const group = scope.group || 'source shape' | |
| const scopePath = Array.isArray(scope.path) ? scope.path : ['source', scope.id] | |
| const policy = scope.gate || { enabled: false, minBaselineSources: 3, minCurrentSamples: 1 } | |
| observations.push( | |
| { | |
| id: 'source.lines', | |
| label: scope.label + ' lines', | |
| group, | |
| path: scopePath, | |
| description: 'Tracked non-binary source lines in the configured scope.', | |
| measurementKind: 'deterministic', | |
| name: 'source.lines', | |
| unit: 'lines', | |
| value: lineCount, | |
| dimensions: { scope: scope.id }, | |
| policy, | |
| statistics: { sampleCount: 1, measuredSampleCount: measuredFileCount }, | |
| }, | |
| { | |
| id: 'source.files', | |
| label: scope.label + ' files', | |
| group, | |
| path: scopePath, | |
| description: 'Tracked non-binary source files in the configured scope.', | |
| measurementKind: 'deterministic', | |
| name: 'source.files', | |
| unit: 'count', | |
| value: measuredFileCount, | |
| dimensions: { scope: scope.id }, | |
| policy, | |
| statistics: { sampleCount: 1, measuredSampleCount: measuredFileCount }, | |
| }, | |
| ) | |
| scopeSummaries.push({ | |
| id: scope.id, | |
| label: scope.label, | |
| root, | |
| includePaths, | |
| excludePaths: scope.excludePaths || [], | |
| includeExtensions: scope.includeExtensions || [], | |
| fileCount: measuredFileCount, | |
| lineCount, | |
| }) | |
| } | |
| const artifact = { | |
| schemaVersion: 1, | |
| generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), | |
| producer: { | |
| name: 'effect-utils-ci-measurement', | |
| version: 1, | |
| measurementProtocol: 'source-shape-v1', | |
| }, | |
| subject: { | |
| repo: process.env.GITHUB_REPOSITORY || 'unknown', | |
| branchKind: process.env.GITHUB_EVENT_NAME || 'unknown', | |
| ref: process.env.CI_MEASUREMENT_SUBJECT_REF || process.env.GITHUB_REF || 'unknown', | |
| headSha: process.env.CI_MEASUREMENT_SUBJECT_SHA || process.env.GITHUB_SHA || 'unknown', | |
| baseSha: process.env.GITHUB_BASE_SHA || '', | |
| }, | |
| execution: { | |
| provider: process.env.GITHUB_RUN_ID && process.env.GITHUB_RUN_ID !== 'unknown' ? 'github-actions' : 'local', | |
| workflow: 'CI', | |
| job: process.env.GITHUB_JOB || 'unknown', | |
| runId: process.env.GITHUB_RUN_ID || 'unknown', | |
| runAttempt: process.env.GITHUB_RUN_ATTEMPT || 'unknown', | |
| taskId: process.env.CROSSTASK_TASK_ID || '', | |
| attemptId: process.env.CROSSTASK_ATTEMPT_ID || '', | |
| traceId: process.env.TRACE_ID || '', | |
| runner: { | |
| name: process.env.RUNNER_NAME || 'unknown', | |
| os: process.env.RUNNER_OS || 'unknown', | |
| arch: process.env.RUNNER_ARCH || 'unknown', | |
| class: process.env.RUNNER_CLASS || 'unknown', | |
| }, | |
| }, | |
| target: { | |
| kind: 'source-shape', | |
| id: process.env.TARGET_ID, | |
| name: process.env.TARGET_NAME, | |
| label: process.env.TARGET_LABEL, | |
| group: process.env.TARGET_GROUP, | |
| path: targetPath, | |
| system: process.env.TARGET_SYSTEM, | |
| }, | |
| observations, | |
| details: { scopes: scopeSummaries }, | |
| } | |
| process.stdout.write(JSON.stringify(artifact, null, 2) + '\n') | |
| NODE | |
| cat "$artifact_file" | |
| - name: 'Upload CI measurements: source-shape' | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: source-shape | |
| path: | | |
| tmp/source-shape-ci | |
| !tmp/source-shape-ci/baseline/** | |
| if-no-files-found: error | |
| retention-days: 14 | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-source-shape" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| ci-measurements-report: | |
| name: ci/measurements-report | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| needs: [devenv-perf, nix-closure-sizes, source-shape] | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| defaults: | |
| run: | |
| shell: bash | |
| permissions: | |
| actions: read | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| env: | |
| CI_MEASUREMENT_SUBJECT_REF: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.ref || github.ref }} | |
| CI_MEASUREMENT_SUBJECT_SHA: ${{ inputs.measurement_baseline_ref || github.event.pull_request.head.sha || github.sha }} | |
| CI_MEASUREMENT_SUBJECT_LABEL: ${{ inputs.measurement_baseline_label }} | |
| CI_MEASUREMENT_ALLOW_PROBE_FAILURES: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && '1' || '' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide CI measurement report tools | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for out in $(nix build --no-link --print-out-paths nixpkgs#jq nixpkgs#nodejs nixpkgs#gh nixpkgs#resvg); do | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| done | |
| - name: 'Download current measurement artifact: devenv-perf' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: devenv-perf | |
| path: tmp/ci-measurement-report/current/devenv-perf | |
| - name: 'Download current measurement artifact: nix-closure-measurements' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: nix-closure-measurements | |
| path: tmp/ci-measurement-report/current/nix-closure-measurements | |
| - name: 'Download current measurement artifact: source-shape' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: source-shape | |
| path: tmp/ci-measurement-report/current/source-shape | |
| - name: 'Download previous artifact: devenv-perf' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASELINE_ARTIFACT_NAME: devenv-perf | |
| BASELINE_OUTPUT_DIR: tmp/ci-measurement-report/baseline/devenv-perf | |
| BASELINE_WORKFLOW_NAME: ${{ github.workflow }} | |
| BASELINE_BRANCH: ${{ github.base_ref || github.ref_name }} | |
| BASELINE_SEED_RUNS_JSON: '[]' | |
| BASELINE_MAX_RUNS: '20' | |
| BASELINE_MAX_CANDIDATE_RUNS: '60' | |
| BASELINE_REQUIRED_OBSERVATIONS_JSON: '[]' | |
| BASELINE_DOWNLOAD_TIMEOUT_SECONDS: '120' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$BASELINE_OUTPUT_DIR" | |
| if command -v gh >/dev/null 2>&1; then | |
| GH_BIN="$(command -v gh)" | |
| else | |
| echo "::notice::gh is not on PATH; resolving GitHub CLI through Nix" | |
| if ! GH_BIN="$(nix build --no-link --print-out-paths nixpkgs#gh 2>/dev/null)/bin/gh"; then | |
| echo "::notice::unable to resolve GitHub CLI through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using GitHub CLI: $GH_BIN" | |
| CURL_BIN="$(command -v curl || true)" | |
| if [ -z "$CURL_BIN" ]; then | |
| echo "::notice::curl is not on PATH; resolving curl through Nix" | |
| if ! CURL_BIN="$(nix build --no-link --print-out-paths nixpkgs#curl 2>/dev/null)/bin/curl"; then | |
| echo "::notice::unable to resolve curl through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using curl: $CURL_BIN" | |
| UNZIP_BIN="$(command -v unzip || true)" | |
| if [ -z "$UNZIP_BIN" ]; then | |
| echo "::notice::unzip is not on PATH; resolving unzip through Nix" | |
| if ! UNZIP_BIN="$(nix build --no-link --print-out-paths nixpkgs#unzip 2>/dev/null)/bin/unzip"; then | |
| echo "::notice::unable to resolve unzip through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using unzip: $UNZIP_BIN" | |
| repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}" | |
| workflow="${BASELINE_WORKFLOW_NAME:-CI}" | |
| branch="${BASELINE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}}" | |
| seed_runs_file="$BASELINE_OUTPUT_DIR/baseline-seed-runs.json" | |
| required_observations_file="$BASELINE_OUTPUT_DIR/baseline-required-observations.json" | |
| printf '%s' "${BASELINE_SEED_RUNS_JSON:-[]}" >"$seed_runs_file" | |
| printf '%s' "${BASELINE_REQUIRED_OBSERVATIONS_JSON:-[]}" >"$required_observations_file" | |
| if ! jq -e 'if type == "array" then all(.[]; type == "object" and (.runId | type == "string")) else false end' \ | |
| "$seed_runs_file" >/dev/null; then | |
| echo "::error::BASELINE_SEED_RUNS_JSON must be an array of objects with string runId fields" | |
| exit 1 | |
| fi | |
| if ! jq -e 'if type == "array" then all(.[]; type == "object" and (.id | type == "string") and (.minSources | type == "number")) else false end' \ | |
| "$required_observations_file" >/dev/null; then | |
| echo "::error::BASELINE_REQUIRED_OBSERVATIONS_JSON must be an array of objects with string id and numeric minSources fields" | |
| exit 1 | |
| fi | |
| seed_run_ids="$(jq -r '.[].runId' "$seed_runs_file")" | |
| required_observation_count="$(jq 'length' "$required_observations_file")" | |
| max_candidate_runs="${BASELINE_MAX_CANDIDATE_RUNS:-${BASELINE_MAX_RUNS:-5}}" | |
| if ! [[ "$max_candidate_runs" =~ ^[0-9]+$ ]] || [ "$max_candidate_runs" -lt 1 ]; then | |
| max_candidate_runs=1 | |
| fi | |
| candidate_runs="$( | |
| "$GH_BIN" run list \ | |
| --repo "$repo" \ | |
| --workflow "$workflow" \ | |
| --branch "$branch" \ | |
| --event push \ | |
| --status success \ | |
| --json databaseId,headSha \ | |
| --limit "$max_candidate_runs" \ | |
| --jq '[.[] | select(.headSha != env.GITHUB_SHA) | .databaseId] | .[]' | |
| )" | |
| candidate_runs="$seed_run_ids | |
| $candidate_runs" | |
| max_runs="${BASELINE_MAX_RUNS:-5}" | |
| if ! [[ "$max_runs" =~ ^[0-9]+$ ]] || [ "$max_runs" -lt 1 ]; then | |
| max_runs=1 | |
| fi | |
| download_timeout_seconds="${BASELINE_DOWNLOAD_TIMEOUT_SECONDS:-120}" | |
| if ! [[ "$download_timeout_seconds" =~ ^[0-9]+$ ]] || [ "$download_timeout_seconds" -lt 1 ]; then | |
| download_timeout_seconds=120 | |
| fi | |
| write_baseline_observation_counts() { | |
| local measurement_index="$BASELINE_OUTPUT_DIR/baseline-measurement-files.txt" | |
| local counts_file="$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" | |
| find "$BASELINE_OUTPUT_DIR" \ | |
| -mindepth 2 \ | |
| -maxdepth 2 \ | |
| -name measurements.json \ | |
| -type f \ | |
| -print \ | |
| | sort >"$measurement_index" || true | |
| if [ -s "$measurement_index" ]; then | |
| xargs -r jq -s \ | |
| --slurpfile required "$required_observations_file" \ | |
| ' | |
| ([.[] | (.observations // [])[]? | select(.value | type == "number") | .id] | sort | group_by(.) | map({id: .[0], sources: length})) as $counts | |
| | ($required[0] // []) as $requiredRows | |
| | { | |
| counts: $counts, | |
| required: ( | |
| $requiredRows | |
| | map(. as $requiredRow | ($counts | map(select(.id == $requiredRow.id)) | .[0].sources // 0) as $actual | $requiredRow + {sources:$actual, satisfied:($actual >= $requiredRow.minSources)}) | |
| ) | |
| } | |
| ' <"$measurement_index" >"$counts_file" | |
| else | |
| jq -n --slurpfile required "$required_observations_file" \ | |
| '{counts: [], required: (($required[0] // []) | map(. + {sources:0, satisfied:false}))}' >"$counts_file" | |
| fi | |
| } | |
| baseline_requirements_satisfied() { | |
| if [ "$required_observation_count" -eq 0 ]; then | |
| return 1 | |
| fi | |
| write_baseline_observation_counts | |
| jq -e '.required | all(.satisfied == true)' "$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" >/dev/null | |
| } | |
| download_artifact_archive() { | |
| local artifact_id="$1" | |
| local output_dir="$2" | |
| local archive_file="$output_dir/artifact.zip" | |
| local artifact_url="https://api.github.com/repos/$repo/actions/artifacts/$artifact_id/zip" | |
| local curl_args=( | |
| --fail | |
| --location | |
| --silent | |
| --show-error | |
| --connect-timeout 15 | |
| --max-time "$download_timeout_seconds" | |
| --retry 2 | |
| --retry-delay 2 | |
| --retry-max-time "$download_timeout_seconds" | |
| --header "Accept: application/vnd.github+json" | |
| --header "X-GitHub-Api-Version: 2022-11-28" | |
| --output "$archive_file" | |
| ) | |
| if [ -n "${GH_TOKEN:-}" ]; then | |
| curl_args+=(--header "Authorization: Bearer ${GH_TOKEN}") | |
| fi | |
| "$CURL_BIN" "${curl_args[@]}" "$artifact_url" | |
| "$UNZIP_BIN" -q -o "$archive_file" -d "$output_dir" | |
| rm -f "$archive_file" | |
| } | |
| run_id="" | |
| artifact_name="" | |
| artifact_id="" | |
| downloaded_runs_file="$BASELINE_OUTPUT_DIR/baseline-runs.jsonl" | |
| seen_runs_file="$BASELINE_OUTPUT_DIR/baseline-seen-runs.txt" | |
| : >"$downloaded_runs_file" | |
| : >"$seen_runs_file" | |
| for candidate_run in $candidate_runs; do | |
| if [ -z "$candidate_run" ]; then | |
| continue | |
| fi | |
| if grep -qxF "$candidate_run" "$seen_runs_file"; then | |
| continue | |
| fi | |
| downloaded_count="$(wc -l <"$downloaded_runs_file" | tr -d ' ')" | |
| if [ "$downloaded_count" -ge "$max_runs" ]; then | |
| if baseline_requirements_satisfied; then | |
| break | |
| fi | |
| echo "::notice::downloaded $downloaded_count baseline artifact(s), but required observation counts are not satisfied yet; continuing through bounded candidate history" | |
| fi | |
| if [ "$(wc -l <"$seen_runs_file" | tr -d ' ')" -ge "$max_candidate_runs" ]; then | |
| break | |
| fi | |
| printf '%s\n' "$candidate_run" >>"$seen_runs_file" | |
| artifact_json="$( | |
| "$GH_BIN" api "repos/$repo/actions/runs/$candidate_run/artifacts" \ | |
| | jq --arg artifactName "$BASELINE_ARTIFACT_NAME" '.artifacts | |
| | map(select(.expired == false)) | |
| | map(select(.name == $artifactName or (.name | startswith($artifactName + "-")))) | |
| | sort_by(.created_at // "") | |
| | reverse | |
| | .[0] // empty' | |
| )" | |
| if [ -n "$artifact_json" ]; then | |
| current_artifact_name="$(printf '%s' "$artifact_json" | jq -r '.name')" | |
| current_artifact_id="$(printf '%s' "$artifact_json" | jq -r '.id')" | |
| current_output_dir="$BASELINE_OUTPUT_DIR/run-$candidate_run" | |
| mkdir -p "$current_output_dir" | |
| if download_artifact_archive "$current_artifact_id" "$current_output_dir"; then | |
| if [ -z "$run_id" ]; then | |
| run_id="$candidate_run" | |
| artifact_name="$current_artifact_name" | |
| artifact_id="$current_artifact_id" | |
| fi | |
| jq -cn \ | |
| --arg runId "$candidate_run" \ | |
| --arg artifactName "$current_artifact_name" \ | |
| --arg artifactId "$current_artifact_id" \ | |
| --arg path "run-$candidate_run" \ | |
| '{runId:$runId, artifactName:$artifactName, artifactId:$artifactId, path:$path}' \ | |
| >>"$downloaded_runs_file" | |
| else | |
| status="$?" | |
| rm -rf "$current_output_dir" | |
| echo "::notice::failed or timed out after ${download_timeout_seconds}s downloading baseline artifact $current_artifact_name from run $candidate_run (exit $status); skipping candidate" | |
| fi | |
| fi | |
| done | |
| write_baseline_observation_counts | |
| if [ -z "$run_id" ] || [ -z "$artifact_name" ]; then | |
| echo "::notice::no successful baseline run found for $repo workflow=$workflow branch=$branch" | |
| exit 0 | |
| fi | |
| jq -n \ | |
| --slurpfile runs "$downloaded_runs_file" \ | |
| --slurpfile seedRuns "$seed_runs_file" \ | |
| --slurpfile observationCounts "$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" \ | |
| --argjson schemaVersion 1 \ | |
| --arg repository "$repo" \ | |
| --arg workflow "$workflow" \ | |
| --arg branch "$branch" \ | |
| --arg runId "$run_id" \ | |
| --arg artifactName "$artifact_name" \ | |
| --arg artifactId "$artifact_id" \ | |
| '{ | |
| schemaVersion: $schemaVersion, | |
| source: "github-actions-artifact", | |
| repository: $repository, | |
| workflow: $workflow, | |
| branch: $branch, | |
| runId: $runId, | |
| artifactName: $artifactName, | |
| artifactId: $artifactId, | |
| seedRuns: ($seedRuns[0] // []), | |
| runs: $runs, | |
| observationCounts: ($observationCounts[0] // null) | |
| }' >"$BASELINE_OUTPUT_DIR/baseline-provenance.json" | |
| echo "Downloaded $(wc -l <"$downloaded_runs_file" | tr -d ' ') baseline artifact(s), latest $artifact_name from run $run_id into $BASELINE_OUTPUT_DIR" | |
| - name: 'Download previous artifact: nix-closure-measurements' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASELINE_ARTIFACT_NAME: nix-closure-measurements | |
| BASELINE_OUTPUT_DIR: tmp/ci-measurement-report/baseline/nix-closure-measurements | |
| BASELINE_WORKFLOW_NAME: ${{ github.workflow }} | |
| BASELINE_BRANCH: ${{ github.base_ref || github.ref_name }} | |
| BASELINE_SEED_RUNS_JSON: '[]' | |
| BASELINE_MAX_RUNS: '20' | |
| BASELINE_MAX_CANDIDATE_RUNS: '60' | |
| BASELINE_REQUIRED_OBSERVATIONS_JSON: '[]' | |
| BASELINE_DOWNLOAD_TIMEOUT_SECONDS: '120' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$BASELINE_OUTPUT_DIR" | |
| if command -v gh >/dev/null 2>&1; then | |
| GH_BIN="$(command -v gh)" | |
| else | |
| echo "::notice::gh is not on PATH; resolving GitHub CLI through Nix" | |
| if ! GH_BIN="$(nix build --no-link --print-out-paths nixpkgs#gh 2>/dev/null)/bin/gh"; then | |
| echo "::notice::unable to resolve GitHub CLI through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using GitHub CLI: $GH_BIN" | |
| CURL_BIN="$(command -v curl || true)" | |
| if [ -z "$CURL_BIN" ]; then | |
| echo "::notice::curl is not on PATH; resolving curl through Nix" | |
| if ! CURL_BIN="$(nix build --no-link --print-out-paths nixpkgs#curl 2>/dev/null)/bin/curl"; then | |
| echo "::notice::unable to resolve curl through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using curl: $CURL_BIN" | |
| UNZIP_BIN="$(command -v unzip || true)" | |
| if [ -z "$UNZIP_BIN" ]; then | |
| echo "::notice::unzip is not on PATH; resolving unzip through Nix" | |
| if ! UNZIP_BIN="$(nix build --no-link --print-out-paths nixpkgs#unzip 2>/dev/null)/bin/unzip"; then | |
| echo "::notice::unable to resolve unzip through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using unzip: $UNZIP_BIN" | |
| repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}" | |
| workflow="${BASELINE_WORKFLOW_NAME:-CI}" | |
| branch="${BASELINE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}}" | |
| seed_runs_file="$BASELINE_OUTPUT_DIR/baseline-seed-runs.json" | |
| required_observations_file="$BASELINE_OUTPUT_DIR/baseline-required-observations.json" | |
| printf '%s' "${BASELINE_SEED_RUNS_JSON:-[]}" >"$seed_runs_file" | |
| printf '%s' "${BASELINE_REQUIRED_OBSERVATIONS_JSON:-[]}" >"$required_observations_file" | |
| if ! jq -e 'if type == "array" then all(.[]; type == "object" and (.runId | type == "string")) else false end' \ | |
| "$seed_runs_file" >/dev/null; then | |
| echo "::error::BASELINE_SEED_RUNS_JSON must be an array of objects with string runId fields" | |
| exit 1 | |
| fi | |
| if ! jq -e 'if type == "array" then all(.[]; type == "object" and (.id | type == "string") and (.minSources | type == "number")) else false end' \ | |
| "$required_observations_file" >/dev/null; then | |
| echo "::error::BASELINE_REQUIRED_OBSERVATIONS_JSON must be an array of objects with string id and numeric minSources fields" | |
| exit 1 | |
| fi | |
| seed_run_ids="$(jq -r '.[].runId' "$seed_runs_file")" | |
| required_observation_count="$(jq 'length' "$required_observations_file")" | |
| max_candidate_runs="${BASELINE_MAX_CANDIDATE_RUNS:-${BASELINE_MAX_RUNS:-5}}" | |
| if ! [[ "$max_candidate_runs" =~ ^[0-9]+$ ]] || [ "$max_candidate_runs" -lt 1 ]; then | |
| max_candidate_runs=1 | |
| fi | |
| candidate_runs="$( | |
| "$GH_BIN" run list \ | |
| --repo "$repo" \ | |
| --workflow "$workflow" \ | |
| --branch "$branch" \ | |
| --event push \ | |
| --status success \ | |
| --json databaseId,headSha \ | |
| --limit "$max_candidate_runs" \ | |
| --jq '[.[] | select(.headSha != env.GITHUB_SHA) | .databaseId] | .[]' | |
| )" | |
| candidate_runs="$seed_run_ids | |
| $candidate_runs" | |
| max_runs="${BASELINE_MAX_RUNS:-5}" | |
| if ! [[ "$max_runs" =~ ^[0-9]+$ ]] || [ "$max_runs" -lt 1 ]; then | |
| max_runs=1 | |
| fi | |
| download_timeout_seconds="${BASELINE_DOWNLOAD_TIMEOUT_SECONDS:-120}" | |
| if ! [[ "$download_timeout_seconds" =~ ^[0-9]+$ ]] || [ "$download_timeout_seconds" -lt 1 ]; then | |
| download_timeout_seconds=120 | |
| fi | |
| write_baseline_observation_counts() { | |
| local measurement_index="$BASELINE_OUTPUT_DIR/baseline-measurement-files.txt" | |
| local counts_file="$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" | |
| find "$BASELINE_OUTPUT_DIR" \ | |
| -mindepth 2 \ | |
| -maxdepth 2 \ | |
| -name measurements.json \ | |
| -type f \ | |
| -print \ | |
| | sort >"$measurement_index" || true | |
| if [ -s "$measurement_index" ]; then | |
| xargs -r jq -s \ | |
| --slurpfile required "$required_observations_file" \ | |
| ' | |
| ([.[] | (.observations // [])[]? | select(.value | type == "number") | .id] | sort | group_by(.) | map({id: .[0], sources: length})) as $counts | |
| | ($required[0] // []) as $requiredRows | |
| | { | |
| counts: $counts, | |
| required: ( | |
| $requiredRows | |
| | map(. as $requiredRow | ($counts | map(select(.id == $requiredRow.id)) | .[0].sources // 0) as $actual | $requiredRow + {sources:$actual, satisfied:($actual >= $requiredRow.minSources)}) | |
| ) | |
| } | |
| ' <"$measurement_index" >"$counts_file" | |
| else | |
| jq -n --slurpfile required "$required_observations_file" \ | |
| '{counts: [], required: (($required[0] // []) | map(. + {sources:0, satisfied:false}))}' >"$counts_file" | |
| fi | |
| } | |
| baseline_requirements_satisfied() { | |
| if [ "$required_observation_count" -eq 0 ]; then | |
| return 1 | |
| fi | |
| write_baseline_observation_counts | |
| jq -e '.required | all(.satisfied == true)' "$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" >/dev/null | |
| } | |
| download_artifact_archive() { | |
| local artifact_id="$1" | |
| local output_dir="$2" | |
| local archive_file="$output_dir/artifact.zip" | |
| local artifact_url="https://api.github.com/repos/$repo/actions/artifacts/$artifact_id/zip" | |
| local curl_args=( | |
| --fail | |
| --location | |
| --silent | |
| --show-error | |
| --connect-timeout 15 | |
| --max-time "$download_timeout_seconds" | |
| --retry 2 | |
| --retry-delay 2 | |
| --retry-max-time "$download_timeout_seconds" | |
| --header "Accept: application/vnd.github+json" | |
| --header "X-GitHub-Api-Version: 2022-11-28" | |
| --output "$archive_file" | |
| ) | |
| if [ -n "${GH_TOKEN:-}" ]; then | |
| curl_args+=(--header "Authorization: Bearer ${GH_TOKEN}") | |
| fi | |
| "$CURL_BIN" "${curl_args[@]}" "$artifact_url" | |
| "$UNZIP_BIN" -q -o "$archive_file" -d "$output_dir" | |
| rm -f "$archive_file" | |
| } | |
| run_id="" | |
| artifact_name="" | |
| artifact_id="" | |
| downloaded_runs_file="$BASELINE_OUTPUT_DIR/baseline-runs.jsonl" | |
| seen_runs_file="$BASELINE_OUTPUT_DIR/baseline-seen-runs.txt" | |
| : >"$downloaded_runs_file" | |
| : >"$seen_runs_file" | |
| for candidate_run in $candidate_runs; do | |
| if [ -z "$candidate_run" ]; then | |
| continue | |
| fi | |
| if grep -qxF "$candidate_run" "$seen_runs_file"; then | |
| continue | |
| fi | |
| downloaded_count="$(wc -l <"$downloaded_runs_file" | tr -d ' ')" | |
| if [ "$downloaded_count" -ge "$max_runs" ]; then | |
| if baseline_requirements_satisfied; then | |
| break | |
| fi | |
| echo "::notice::downloaded $downloaded_count baseline artifact(s), but required observation counts are not satisfied yet; continuing through bounded candidate history" | |
| fi | |
| if [ "$(wc -l <"$seen_runs_file" | tr -d ' ')" -ge "$max_candidate_runs" ]; then | |
| break | |
| fi | |
| printf '%s\n' "$candidate_run" >>"$seen_runs_file" | |
| artifact_json="$( | |
| "$GH_BIN" api "repos/$repo/actions/runs/$candidate_run/artifacts" \ | |
| | jq --arg artifactName "$BASELINE_ARTIFACT_NAME" '.artifacts | |
| | map(select(.expired == false)) | |
| | map(select(.name == $artifactName or (.name | startswith($artifactName + "-")))) | |
| | sort_by(.created_at // "") | |
| | reverse | |
| | .[0] // empty' | |
| )" | |
| if [ -n "$artifact_json" ]; then | |
| current_artifact_name="$(printf '%s' "$artifact_json" | jq -r '.name')" | |
| current_artifact_id="$(printf '%s' "$artifact_json" | jq -r '.id')" | |
| current_output_dir="$BASELINE_OUTPUT_DIR/run-$candidate_run" | |
| mkdir -p "$current_output_dir" | |
| if download_artifact_archive "$current_artifact_id" "$current_output_dir"; then | |
| if [ -z "$run_id" ]; then | |
| run_id="$candidate_run" | |
| artifact_name="$current_artifact_name" | |
| artifact_id="$current_artifact_id" | |
| fi | |
| jq -cn \ | |
| --arg runId "$candidate_run" \ | |
| --arg artifactName "$current_artifact_name" \ | |
| --arg artifactId "$current_artifact_id" \ | |
| --arg path "run-$candidate_run" \ | |
| '{runId:$runId, artifactName:$artifactName, artifactId:$artifactId, path:$path}' \ | |
| >>"$downloaded_runs_file" | |
| else | |
| status="$?" | |
| rm -rf "$current_output_dir" | |
| echo "::notice::failed or timed out after ${download_timeout_seconds}s downloading baseline artifact $current_artifact_name from run $candidate_run (exit $status); skipping candidate" | |
| fi | |
| fi | |
| done | |
| write_baseline_observation_counts | |
| if [ -z "$run_id" ] || [ -z "$artifact_name" ]; then | |
| echo "::notice::no successful baseline run found for $repo workflow=$workflow branch=$branch" | |
| exit 0 | |
| fi | |
| jq -n \ | |
| --slurpfile runs "$downloaded_runs_file" \ | |
| --slurpfile seedRuns "$seed_runs_file" \ | |
| --slurpfile observationCounts "$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" \ | |
| --argjson schemaVersion 1 \ | |
| --arg repository "$repo" \ | |
| --arg workflow "$workflow" \ | |
| --arg branch "$branch" \ | |
| --arg runId "$run_id" \ | |
| --arg artifactName "$artifact_name" \ | |
| --arg artifactId "$artifact_id" \ | |
| '{ | |
| schemaVersion: $schemaVersion, | |
| source: "github-actions-artifact", | |
| repository: $repository, | |
| workflow: $workflow, | |
| branch: $branch, | |
| runId: $runId, | |
| artifactName: $artifactName, | |
| artifactId: $artifactId, | |
| seedRuns: ($seedRuns[0] // []), | |
| runs: $runs, | |
| observationCounts: ($observationCounts[0] // null) | |
| }' >"$BASELINE_OUTPUT_DIR/baseline-provenance.json" | |
| echo "Downloaded $(wc -l <"$downloaded_runs_file" | tr -d ' ') baseline artifact(s), latest $artifact_name from run $run_id into $BASELINE_OUTPUT_DIR" | |
| - name: 'Download previous artifact: source-shape' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASELINE_ARTIFACT_NAME: source-shape | |
| BASELINE_OUTPUT_DIR: tmp/ci-measurement-report/baseline/source-shape | |
| BASELINE_WORKFLOW_NAME: ${{ github.workflow }} | |
| BASELINE_BRANCH: ${{ github.base_ref || github.ref_name }} | |
| BASELINE_SEED_RUNS_JSON: '[{"runId":"26085158592","label":"main baseline","sha":"ce7cf8f8ebfaa1da6c7e9122cd195a5f95ce2fca","source":"manual-backfill","artifacts":["source-shape"],"notes":"Backfilled with the current measurement workflow for the effect-utils #658 rollout."}]' | |
| BASELINE_MAX_RUNS: '20' | |
| BASELINE_MAX_CANDIDATE_RUNS: '60' | |
| BASELINE_REQUIRED_OBSERVATIONS_JSON: '[]' | |
| BASELINE_DOWNLOAD_TIMEOUT_SECONDS: '120' | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$BASELINE_OUTPUT_DIR" | |
| if command -v gh >/dev/null 2>&1; then | |
| GH_BIN="$(command -v gh)" | |
| else | |
| echo "::notice::gh is not on PATH; resolving GitHub CLI through Nix" | |
| if ! GH_BIN="$(nix build --no-link --print-out-paths nixpkgs#gh 2>/dev/null)/bin/gh"; then | |
| echo "::notice::unable to resolve GitHub CLI through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using GitHub CLI: $GH_BIN" | |
| CURL_BIN="$(command -v curl || true)" | |
| if [ -z "$CURL_BIN" ]; then | |
| echo "::notice::curl is not on PATH; resolving curl through Nix" | |
| if ! CURL_BIN="$(nix build --no-link --print-out-paths nixpkgs#curl 2>/dev/null)/bin/curl"; then | |
| echo "::notice::unable to resolve curl through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using curl: $CURL_BIN" | |
| UNZIP_BIN="$(command -v unzip || true)" | |
| if [ -z "$UNZIP_BIN" ]; then | |
| echo "::notice::unzip is not on PATH; resolving unzip through Nix" | |
| if ! UNZIP_BIN="$(nix build --no-link --print-out-paths nixpkgs#unzip 2>/dev/null)/bin/unzip"; then | |
| echo "::notice::unable to resolve unzip through Nix; skipping previous artifact download" | |
| exit 0 | |
| fi | |
| fi | |
| echo "Using unzip: $UNZIP_BIN" | |
| repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}" | |
| workflow="${BASELINE_WORKFLOW_NAME:-CI}" | |
| branch="${BASELINE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-main}}}" | |
| seed_runs_file="$BASELINE_OUTPUT_DIR/baseline-seed-runs.json" | |
| required_observations_file="$BASELINE_OUTPUT_DIR/baseline-required-observations.json" | |
| printf '%s' "${BASELINE_SEED_RUNS_JSON:-[]}" >"$seed_runs_file" | |
| printf '%s' "${BASELINE_REQUIRED_OBSERVATIONS_JSON:-[]}" >"$required_observations_file" | |
| if ! jq -e 'if type == "array" then all(.[]; type == "object" and (.runId | type == "string")) else false end' \ | |
| "$seed_runs_file" >/dev/null; then | |
| echo "::error::BASELINE_SEED_RUNS_JSON must be an array of objects with string runId fields" | |
| exit 1 | |
| fi | |
| if ! jq -e 'if type == "array" then all(.[]; type == "object" and (.id | type == "string") and (.minSources | type == "number")) else false end' \ | |
| "$required_observations_file" >/dev/null; then | |
| echo "::error::BASELINE_REQUIRED_OBSERVATIONS_JSON must be an array of objects with string id and numeric minSources fields" | |
| exit 1 | |
| fi | |
| seed_run_ids="$(jq -r '.[].runId' "$seed_runs_file")" | |
| required_observation_count="$(jq 'length' "$required_observations_file")" | |
| max_candidate_runs="${BASELINE_MAX_CANDIDATE_RUNS:-${BASELINE_MAX_RUNS:-5}}" | |
| if ! [[ "$max_candidate_runs" =~ ^[0-9]+$ ]] || [ "$max_candidate_runs" -lt 1 ]; then | |
| max_candidate_runs=1 | |
| fi | |
| candidate_runs="$( | |
| "$GH_BIN" run list \ | |
| --repo "$repo" \ | |
| --workflow "$workflow" \ | |
| --branch "$branch" \ | |
| --event push \ | |
| --status success \ | |
| --json databaseId,headSha \ | |
| --limit "$max_candidate_runs" \ | |
| --jq '[.[] | select(.headSha != env.GITHUB_SHA) | .databaseId] | .[]' | |
| )" | |
| candidate_runs="$seed_run_ids | |
| $candidate_runs" | |
| max_runs="${BASELINE_MAX_RUNS:-5}" | |
| if ! [[ "$max_runs" =~ ^[0-9]+$ ]] || [ "$max_runs" -lt 1 ]; then | |
| max_runs=1 | |
| fi | |
| download_timeout_seconds="${BASELINE_DOWNLOAD_TIMEOUT_SECONDS:-120}" | |
| if ! [[ "$download_timeout_seconds" =~ ^[0-9]+$ ]] || [ "$download_timeout_seconds" -lt 1 ]; then | |
| download_timeout_seconds=120 | |
| fi | |
| write_baseline_observation_counts() { | |
| local measurement_index="$BASELINE_OUTPUT_DIR/baseline-measurement-files.txt" | |
| local counts_file="$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" | |
| find "$BASELINE_OUTPUT_DIR" \ | |
| -mindepth 2 \ | |
| -maxdepth 2 \ | |
| -name measurements.json \ | |
| -type f \ | |
| -print \ | |
| | sort >"$measurement_index" || true | |
| if [ -s "$measurement_index" ]; then | |
| xargs -r jq -s \ | |
| --slurpfile required "$required_observations_file" \ | |
| ' | |
| ([.[] | (.observations // [])[]? | select(.value | type == "number") | .id] | sort | group_by(.) | map({id: .[0], sources: length})) as $counts | |
| | ($required[0] // []) as $requiredRows | |
| | { | |
| counts: $counts, | |
| required: ( | |
| $requiredRows | |
| | map(. as $requiredRow | ($counts | map(select(.id == $requiredRow.id)) | .[0].sources // 0) as $actual | $requiredRow + {sources:$actual, satisfied:($actual >= $requiredRow.minSources)}) | |
| ) | |
| } | |
| ' <"$measurement_index" >"$counts_file" | |
| else | |
| jq -n --slurpfile required "$required_observations_file" \ | |
| '{counts: [], required: (($required[0] // []) | map(. + {sources:0, satisfied:false}))}' >"$counts_file" | |
| fi | |
| } | |
| baseline_requirements_satisfied() { | |
| if [ "$required_observation_count" -eq 0 ]; then | |
| return 1 | |
| fi | |
| write_baseline_observation_counts | |
| jq -e '.required | all(.satisfied == true)' "$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" >/dev/null | |
| } | |
| download_artifact_archive() { | |
| local artifact_id="$1" | |
| local output_dir="$2" | |
| local archive_file="$output_dir/artifact.zip" | |
| local artifact_url="https://api.github.com/repos/$repo/actions/artifacts/$artifact_id/zip" | |
| local curl_args=( | |
| --fail | |
| --location | |
| --silent | |
| --show-error | |
| --connect-timeout 15 | |
| --max-time "$download_timeout_seconds" | |
| --retry 2 | |
| --retry-delay 2 | |
| --retry-max-time "$download_timeout_seconds" | |
| --header "Accept: application/vnd.github+json" | |
| --header "X-GitHub-Api-Version: 2022-11-28" | |
| --output "$archive_file" | |
| ) | |
| if [ -n "${GH_TOKEN:-}" ]; then | |
| curl_args+=(--header "Authorization: Bearer ${GH_TOKEN}") | |
| fi | |
| "$CURL_BIN" "${curl_args[@]}" "$artifact_url" | |
| "$UNZIP_BIN" -q -o "$archive_file" -d "$output_dir" | |
| rm -f "$archive_file" | |
| } | |
| run_id="" | |
| artifact_name="" | |
| artifact_id="" | |
| downloaded_runs_file="$BASELINE_OUTPUT_DIR/baseline-runs.jsonl" | |
| seen_runs_file="$BASELINE_OUTPUT_DIR/baseline-seen-runs.txt" | |
| : >"$downloaded_runs_file" | |
| : >"$seen_runs_file" | |
| for candidate_run in $candidate_runs; do | |
| if [ -z "$candidate_run" ]; then | |
| continue | |
| fi | |
| if grep -qxF "$candidate_run" "$seen_runs_file"; then | |
| continue | |
| fi | |
| downloaded_count="$(wc -l <"$downloaded_runs_file" | tr -d ' ')" | |
| if [ "$downloaded_count" -ge "$max_runs" ]; then | |
| if baseline_requirements_satisfied; then | |
| break | |
| fi | |
| echo "::notice::downloaded $downloaded_count baseline artifact(s), but required observation counts are not satisfied yet; continuing through bounded candidate history" | |
| fi | |
| if [ "$(wc -l <"$seen_runs_file" | tr -d ' ')" -ge "$max_candidate_runs" ]; then | |
| break | |
| fi | |
| printf '%s\n' "$candidate_run" >>"$seen_runs_file" | |
| artifact_json="$( | |
| "$GH_BIN" api "repos/$repo/actions/runs/$candidate_run/artifacts" \ | |
| | jq --arg artifactName "$BASELINE_ARTIFACT_NAME" '.artifacts | |
| | map(select(.expired == false)) | |
| | map(select(.name == $artifactName or (.name | startswith($artifactName + "-")))) | |
| | sort_by(.created_at // "") | |
| | reverse | |
| | .[0] // empty' | |
| )" | |
| if [ -n "$artifact_json" ]; then | |
| current_artifact_name="$(printf '%s' "$artifact_json" | jq -r '.name')" | |
| current_artifact_id="$(printf '%s' "$artifact_json" | jq -r '.id')" | |
| current_output_dir="$BASELINE_OUTPUT_DIR/run-$candidate_run" | |
| mkdir -p "$current_output_dir" | |
| if download_artifact_archive "$current_artifact_id" "$current_output_dir"; then | |
| if [ -z "$run_id" ]; then | |
| run_id="$candidate_run" | |
| artifact_name="$current_artifact_name" | |
| artifact_id="$current_artifact_id" | |
| fi | |
| jq -cn \ | |
| --arg runId "$candidate_run" \ | |
| --arg artifactName "$current_artifact_name" \ | |
| --arg artifactId "$current_artifact_id" \ | |
| --arg path "run-$candidate_run" \ | |
| '{runId:$runId, artifactName:$artifactName, artifactId:$artifactId, path:$path}' \ | |
| >>"$downloaded_runs_file" | |
| else | |
| status="$?" | |
| rm -rf "$current_output_dir" | |
| echo "::notice::failed or timed out after ${download_timeout_seconds}s downloading baseline artifact $current_artifact_name from run $candidate_run (exit $status); skipping candidate" | |
| fi | |
| fi | |
| done | |
| write_baseline_observation_counts | |
| if [ -z "$run_id" ] || [ -z "$artifact_name" ]; then | |
| echo "::notice::no successful baseline run found for $repo workflow=$workflow branch=$branch" | |
| exit 0 | |
| fi | |
| jq -n \ | |
| --slurpfile runs "$downloaded_runs_file" \ | |
| --slurpfile seedRuns "$seed_runs_file" \ | |
| --slurpfile observationCounts "$BASELINE_OUTPUT_DIR/baseline-observation-counts.json" \ | |
| --argjson schemaVersion 1 \ | |
| --arg repository "$repo" \ | |
| --arg workflow "$workflow" \ | |
| --arg branch "$branch" \ | |
| --arg runId "$run_id" \ | |
| --arg artifactName "$artifact_name" \ | |
| --arg artifactId "$artifact_id" \ | |
| '{ | |
| schemaVersion: $schemaVersion, | |
| source: "github-actions-artifact", | |
| repository: $repository, | |
| workflow: $workflow, | |
| branch: $branch, | |
| runId: $runId, | |
| artifactName: $artifactName, | |
| artifactId: $artifactId, | |
| seedRuns: ($seedRuns[0] // []), | |
| runs: $runs, | |
| observationCounts: ($observationCounts[0] // null) | |
| }' >"$BASELINE_OUTPUT_DIR/baseline-provenance.json" | |
| echo "Downloaded $(wc -l <"$downloaded_runs_file" | tr -d ' ') baseline artifact(s), latest $artifact_name from run $run_id into $BASELINE_OUTPUT_DIR" | |
| - name: Compare CI measurements with baseline | |
| shell: bash | |
| env: | |
| CI_MEASUREMENT_CURRENT_DIR: tmp/ci-measurement-report/current | |
| CI_MEASUREMENT_BASELINE_DIR: tmp/ci-measurement-report/baseline | |
| CI_MEASUREMENT_COMPARISON_FILE: tmp/ci-measurement-report/measurement-comparison.json | |
| CI_MEASUREMENT_REGRESSION_MODE: warn | |
| CI_MEASUREMENT_PR_COMMENT_ENABLED: 'true' | |
| CI_MEASUREMENT_PR_COMMENT_TITLE: CI Measurements | |
| CI_MEASUREMENT_PR_COMMENT_MAX_ROWS: '16' | |
| CI_MEASUREMENT_PR_COMMENT_MAX_HISTORY: '20' | |
| CI_MEASUREMENT_PR_COMMENT_ASSET_BRANCH: ci-measurement-assets | |
| CI_MEASUREMENT_IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && '1' || '' }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| export PATH="/run/current-system/sw/bin:/usr/bin:/bin:$PATH" | |
| current_dir="${CI_MEASUREMENT_CURRENT_DIR:?CI_MEASUREMENT_CURRENT_DIR not set}" | |
| baseline_dir="${CI_MEASUREMENT_BASELINE_DIR:?CI_MEASUREMENT_BASELINE_DIR not set}" | |
| comparison_file="${CI_MEASUREMENT_COMPARISON_FILE:?CI_MEASUREMENT_COMPARISON_FILE not set}" | |
| mode="${CI_MEASUREMENT_REGRESSION_MODE:-warn}" | |
| mkdir -p "$(dirname "$comparison_file")" | |
| if [ "$mode" = "off" ]; then | |
| jq -n --argjson schemaVersion 1 --arg status skipped --arg mode "$mode" \ | |
| '{schemaVersion:$schemaVersion,status:$status,mode:$mode,comparisons:{}}' \ | |
| >"$comparison_file" | |
| exit 0 | |
| fi | |
| current_index="$(mktemp)" | |
| baseline_index="$(mktemp)" | |
| find "$current_dir" -name baseline -type d -prune -o -name measurements.json -type f -print | sort >"$current_index" || true | |
| { | |
| find "$baseline_dir" -name baseline -type d ! -path "$baseline_dir" -prune -o -name measurements.json -type f -print | |
| } | sort -u >"$baseline_index" || true | |
| if [ ! -s "$current_index" ]; then | |
| echo "::error::no current measurements.json files found under $current_dir" | |
| exit 1 | |
| fi | |
| current_json="$comparison_file.current.json" | |
| baseline_json="$comparison_file.baseline.json" | |
| xargs -r jq -s '.' <"$current_index" >"$current_json" | |
| if [ -s "$baseline_index" ]; then | |
| xargs -r jq -s '.' <"$baseline_index" >"$baseline_json" | |
| else | |
| printf '[]\n' >"$baseline_json" | |
| fi | |
| jq -n \ | |
| --slurpfile current "$current_json" \ | |
| --slurpfile baseline "$baseline_json" \ | |
| --argjson schemaVersion 1 \ | |
| --arg mode "$mode" \ | |
| --arg currentDir "$current_dir" \ | |
| --arg baselineDir "$baseline_dir" \ | |
| ' | |
| def identity_dimensions: | |
| (.dimensions // {}) | |
| | to_entries | |
| | map(select(.key as $key | ["devenvRev", "otelServiceName", "status", "probeLabel", "sampleCount", "measuredSampleCount"] | index($key) | not)) | |
| | sort_by(.key) | |
| | map("\(.key)=\(.value|tostring)") | |
| | join(","); | |
| def observation_key($doc): | |
| [ | |
| ($doc.target.kind // "unknown"), | |
| ($doc.target.id // $doc.target.name // "unknown"), | |
| ($doc.target.system // "unknown"), | |
| (.id // .name // "unknown"), | |
| (.unit // "unknown"), | |
| identity_dimensions | |
| ] | join("|"); | |
| def median: | |
| sort as $sorted | |
| | ($sorted | length) as $count | |
| | if $count == 0 then null | |
| elif ($count % 2) == 1 then $sorted[($count / 2 | floor)] | |
| else (($sorted[($count / 2 - 1)] + $sorted[($count / 2)]) / 2) | |
| end; | |
| def percentile($p): | |
| sort as $sorted | |
| | ($sorted | length) as $count | |
| | if $count == 0 then null | |
| else $sorted[(($p * ($count - 1)) | floor)] | |
| end; | |
| def abs_value: if . < 0 then -. else . end; | |
| def observations_by_key($docs): | |
| reduce $docs[]? as $doc | |
| ({}; | |
| reduce (($doc.observations // [])[]? | select(.value | type == "number")) as $obs | |
| (.; | |
| ($obs | observation_key($doc)) as $key | |
| | .[$key] = ((.[$key] // []) + [{ | |
| target: $doc.target, | |
| observation: $obs, | |
| generatedAt: $doc.generatedAt | |
| }]) | |
| ) | |
| ); | |
| def observation_stats($items): | |
| ($items | map(.observation.value)) as $values | |
| | ($items | map(.observation.comparison.baseline // empty)) as $pairedBaselineValues | |
| | ($items | map(.observation.statistics.pairedDeltaMedian // empty)) as $pairedDeltaMedianValues | |
| | ($items | map(.observation.statistics.pairedDeltaP25 // empty)) as $pairedDeltaP25Values | |
| | ($items | map(.observation.statistics.pairedDeltaP75 // empty)) as $pairedDeltaP75Values | |
| | ($items | map(.observation.statistics.pairedDeltaMad // empty)) as $pairedDeltaMadValues | |
| | ($items | map(.observation.statistics.pairedDeltaSamples // []) | add // []) as $pairedDeltaSampleValues | |
| | ($items | map(.observation.statistics.measuredSampleCount // .observation.statistics.sampleCount // 1) | add // ($items | length)) as $sampleCount | |
| | ($values | median) as $median | |
| | { | |
| target: ($items[0].target // {}), | |
| observation: ($items[-1].observation // {}), | |
| measurementKind: ($items[-1].observation.measurementKind // null), | |
| value: $median, | |
| min: ($values | min), | |
| max: ($values | max), | |
| p25: ($values | percentile(0.25)), | |
| p75: ($values | percentile(0.75)), | |
| p95: ($values | percentile(0.95)), | |
| mad: ($values | map(. - $median | if . < 0 then -. else . end) | median), | |
| sourceCount: ($items | length), | |
| sampleCount: $sampleCount, | |
| pairedSampleCount: ($items | map(.observation.statistics.pairedSampleCount // .observation.comparison.pairedSampleCount // 0) | add // 0), | |
| pairedBaselineValue: (if ($pairedBaselineValues | length) == 0 then null else ($pairedBaselineValues | median) end), | |
| pairedDeltaMedianValue: (if ($pairedDeltaMedianValues | length) == 0 then null else ($pairedDeltaMedianValues | median) end), | |
| pairedDeltaP25Value: (if ($pairedDeltaP25Values | length) == 0 then null else ($pairedDeltaP25Values | median) end), | |
| pairedDeltaP75Value: (if ($pairedDeltaP75Values | length) == 0 then null else ($pairedDeltaP75Values | median) end), | |
| pairedDeltaMadValue: (if ($pairedDeltaMadValues | length) == 0 then null else ($pairedDeltaMadValues | median) end), | |
| pairedDeltaSampleValues: $pairedDeltaSampleValues, | |
| generatedAt: ($items[-1].generatedAt // null) | |
| }; | |
| def budget($metric; $unit): | |
| if $metric == "nix.closure.nar_size" then | |
| {warnRatio:1.05, failRatio:1.10, warnAbs:52428800, failAbs:209715200, statisticalToleranceRatio:0.02, statisticalToleranceAbs:10485760} | |
| elif $metric == "nix.closure.bucket.nar_size" then | |
| {warnRatio:1.10, failRatio:1.20, warnAbs:52428800, failAbs:209715200, statisticalToleranceRatio:0.05, statisticalToleranceAbs:10485760} | |
| elif $metric == "nix.closure.path_count" then | |
| {warnRatio:1.05, failRatio:1.10, warnAbs:100, failAbs:500, statisticalToleranceRatio:0.02, statisticalToleranceAbs:10} | |
| elif $unit == "seconds" then | |
| {warnRatio:1.10, failRatio:1.20, warnAbs:0.25, failAbs:1, statisticalToleranceRatio:0.10, statisticalToleranceAbs:0.25} | |
| else | |
| {warnRatio:1.25, failRatio:1.50, warnAbs:1, failAbs:3, statisticalToleranceRatio:0.10, statisticalToleranceAbs:1} | |
| end; | |
| def noise_floor($metric; $unit): | |
| if $metric == "nix.closure.nar_size" or $metric == "nix.closure.bucket.nar_size" then 10485760 | |
| elif $metric == "nix.closure.path_count" then 10 | |
| elif $unit == "seconds" then 0.1 | |
| else 0 | |
| end; | |
| def default_policy($metric; $unit): | |
| budget($metric; $unit) as $b | |
| | noise_floor($metric; $unit) as $noise | |
| | $b + { | |
| enabled:true, | |
| comparisonMode:(if $metric == "nix.closure.nar_size" or $metric == "nix.closure.bucket.nar_size" or $metric == "nix.closure.path_count" or $unit != "seconds" then "budget" else "historical" end), | |
| minBaselineSources:(if $metric == "nix.closure.nar_size" or $metric == "nix.closure.bucket.nar_size" or $metric == "nix.closure.path_count" or $unit != "seconds" then 1 else 10 end), | |
| minCurrentSamples:(if $unit == "seconds" then 3 else 1 end), | |
| minPairedSamples:(if $unit == "seconds" then 5 else 0 end), | |
| noiseFloor:$noise | |
| }; | |
| def observation_policy($obs): | |
| default_policy($obs.name // "unknown"; $obs.unit // "unknown") + ($obs.policy // {}); | |
| def policy_enabled($policy): | |
| if ($policy | has("enabled")) then $policy.enabled else true end; | |
| def classify($metric; $unit; $measurementKind; $policy; $current; $currentP25; $currentP75; $currentMad; $baseline; $baselineMin; $baselineMax; $baselineP25; $baselineP75; $baselineP95; $baselineMad; $currentSamples; $baselineSources; $pairedSamples; $pairedDeltaMedian; $pairedDeltaP25; $pairedDeltaP75; $pairedDeltaMad; $pairedDeltaValues): | |
| $policy as $b | |
| | ($policy.comparisonMode // (if $measurementKind == "deterministic" or $unit != "seconds" then "budget" elif $measurementKind == "diagnostic" then "diagnostic" else "historical" end)) as $comparisonMode | |
| | ($policy.noiseFloor // noise_floor($metric; $unit)) as $noise | |
| | ($current - $baseline) as $delta | |
| | (if $comparisonMode == "paired" and $pairedDeltaMedian != null then $pairedDeltaMedian else $delta end) as $evidenceDelta | |
| | (($policy.pairedEvidenceQuantile // 0.25) | tonumber) as $pairedEvidenceQuantile | |
| | (if $baseline > 0 then ($current / $baseline) else null end) as $ratio | |
| | (($baselineP75 // $baseline) - ($baselineP25 // $baseline)) as $iqr | |
| | (($currentP75 // $current) - ($currentP25 // $current)) as $currentIqr | |
| | (($pairedDeltaP75 // $evidenceDelta) - ($pairedDeltaP25 // $evidenceDelta)) as $pairedDeltaIqr | |
| | ([ | |
| $noise, | |
| (($policy.statisticalToleranceAbs // 0) | tonumber), | |
| (if $baseline > 0 then ($baseline * (($policy.statisticalToleranceRatio // 0) | tonumber)) else 0 end), | |
| (($baselineMad // 0) * 3), | |
| (($iqr // 0) * 1.5) | |
| ] | max) as $robustTolerance | |
| | (if $currentSamples > 1 then ([ | |
| $noise, | |
| (($policy.statisticalToleranceAbs // 0) | tonumber), | |
| (if $current > 0 then ($current * (($policy.statisticalToleranceRatio // 0) | tonumber)) else 0 end), | |
| (($currentMad // 0) * 3), | |
| (($currentIqr // 0) * 1.5) | |
| ] | max) else 0 end) as $currentRobustTolerance | |
| | ([ | |
| $noise, | |
| (($policy.statisticalToleranceAbs // 0) | tonumber), | |
| (if $baseline > 0 then ($baseline * (($policy.statisticalToleranceRatio // 0) | tonumber)) else 0 end), | |
| (($pairedDeltaMad // 0) * 3), | |
| (($pairedDeltaIqr // 0) * 1.5) | |
| ] | max) as $pairedDeltaTolerance | |
| | ($baseline + $robustTolerance) as $robustUpper | |
| | ($baseline - $robustTolerance) as $robustLower | |
| | ($current + $currentRobustTolerance) as $currentRobustUpper | |
| | ($current - $currentRobustTolerance) as $currentRobustLower | |
| | (if $comparisonMode == "paired" and ($pairedDeltaValues | length) > 0 then ($pairedDeltaValues | percentile($pairedEvidenceQuantile)) else ($evidenceDelta - $pairedDeltaTolerance) end) as $evidenceDeltaLower | |
| | (if $comparisonMode == "paired" and ($pairedDeltaValues | length) > 0 then ($pairedDeltaValues | percentile(1 - $pairedEvidenceQuantile)) else ($evidenceDelta + $pairedDeltaTolerance) end) as $evidenceDeltaUpper | |
| | ([($b.warnAbs // 0), (if $baseline > 0 then ($baseline * (($b.warnRatio // 1) - 1)) else 0 end), $noise, 0.000000001] | max) as $warnBudget | |
| | ([($b.failAbs // 0), (if $baseline > 0 then ($baseline * (($b.failRatio // 1) - 1)) else 0 end), $noise, 0.000000001] | max) as $failBudget | |
| | ($comparisonMode != "paired") as $needsHistoricalBaselineCount | |
| | ( | |
| ($current >= $robustLower and $current <= $robustUpper) | |
| or ($currentRobustTolerance > 0 and $currentRobustLower <= $robustUpper and $currentRobustUpper >= $robustLower) | |
| ) as $withinRobustBand | |
| | ($comparisonMode == "historical" and $measurementKind != "deterministic") as $canUseRobustBandSuppression | |
| | ( | |
| $baselineMin != null | |
| and $baselineMax != null | |
| and $current >= $baselineMin | |
| and $current <= $baselineMax | |
| ) as $withinBaselineRange | |
| | ( | |
| if $baseline <= 0 then "unknown" | |
| elif $comparisonMode == "paired" and $evidenceDeltaLower > $failBudget then "fail" | |
| elif $comparisonMode == "paired" and $evidenceDeltaLower > $warnBudget then "warn" | |
| elif $comparisonMode == "paired" then "pass" | |
| elif ($delta > $b.failAbs and $current > ($baseline * $b.failRatio)) then "fail" | |
| elif ($delta > $b.warnAbs and $current > ($baseline * $b.warnRatio)) then "warn" | |
| else "pass" | |
| end | |
| ) as $thresholdStatus | |
| | ( | |
| policy_enabled($policy) == true | |
| and $baseline > 0 | |
| and (if $needsHistoricalBaselineCount then $baselineSources >= ($policy.minBaselineSources // 1) else true end) | |
| and $currentSamples >= ($policy.minCurrentSamples // 1) | |
| and (if $comparisonMode == "paired" then $pairedSamples >= ($policy.minPairedSamples // 1) else true end) | |
| and (if $comparisonMode == "paired" then $pairedDeltaMedian != null else true end) | |
| ) as $gateable | |
| | ( | |
| if (policy_enabled($policy) != true) then "disabled" | |
| elif $baseline <= 0 then "missing_baseline" | |
| elif $needsHistoricalBaselineCount and $baselineSources < ($policy.minBaselineSources // 1) then "low_baseline_count" | |
| elif $currentSamples < ($policy.minCurrentSamples // 1) then "low_current_sample_count" | |
| elif $comparisonMode == "paired" and $pairedSamples < ($policy.minPairedSamples // 1) then "low_paired_sample_count" | |
| elif $comparisonMode == "paired" and $pairedDeltaMedian == null then "missing_paired_delta" | |
| else "eligible" | |
| end | |
| ) as $gateReason | |
| | ( | |
| if $baseline <= 0 then "unknown" | |
| elif (policy_enabled($policy) != true) then "diagnostic" | |
| elif ($delta | abs_value) <= $noise then "noise_floor" | |
| elif $needsHistoricalBaselineCount and $baselineSources < ($policy.minBaselineSources // 1) then "low_baseline_count" | |
| elif $currentSamples < ($policy.minCurrentSamples // 1) then "low_current_sample_count" | |
| elif $comparisonMode == "paired" and $pairedSamples < ($policy.minPairedSamples // 1) then "low_paired_sample_count" | |
| elif $comparisonMode == "paired" and $pairedDeltaMedian == null then "missing_paired_delta" | |
| elif $comparisonMode == "paired" and $thresholdStatus == "pass" and $evidenceDelta > $warnBudget then "paired_uncertain" | |
| elif ($canUseRobustBandSuppression and $thresholdStatus != "pass" and $withinRobustBand) then "within_robust_band" | |
| elif $thresholdStatus == "pass" then "within_budget" | |
| else "threshold_exceeded" | |
| end | |
| ) as $confidence | |
| | ( | |
| if ($gateable and $confidence == "threshold_exceeded") then $thresholdStatus | |
| elif $thresholdStatus == "unknown" then "unknown" | |
| else "pass" | |
| end | |
| ) as $status | |
| | ( | |
| if $baseline <= 0 then "unknown" | |
| elif $comparisonMode == "paired" and ($evidenceDelta | abs_value) <= $noise then "unchanged" | |
| elif $comparisonMode == "paired" and $evidenceDeltaLower <= 0 and $evidenceDeltaUpper >= 0 then "unchanged" | |
| elif $comparisonMode == "paired" and $evidenceDelta < 0 then "improved" | |
| elif $comparisonMode == "paired" then "regressed" | |
| elif ($delta | abs_value) <= $noise then "unchanged" | |
| elif $canUseRobustBandSuppression and $withinRobustBand then "unchanged" | |
| elif $delta < 0 then "improved" | |
| else "regressed" | |
| end | |
| ) as $direction | |
| | ( | |
| if $baseline <= 0 then null | |
| elif (policy_enabled($policy) != true) then null | |
| elif $comparisonMode == "paired" and ($evidenceDeltaLower <= 0 and $evidenceDeltaUpper >= 0) then 0 | |
| elif $comparisonMode == "paired" and ($evidenceDelta | abs_value) <= $noise then 0 | |
| elif $comparisonMode == "paired" and $evidenceDelta > 0 then ([0, $evidenceDeltaLower] | max) / $warnBudget | |
| elif $comparisonMode == "paired" then -(([0, (-$evidenceDeltaUpper)] | max) / $warnBudget) | |
| elif $canUseRobustBandSuppression and $withinRobustBand then 0 | |
| elif ($delta | abs_value) <= $noise then 0 | |
| elif ($confidence == "threshold_exceeded" and $delta > 0) then ([0, ($currentRobustLower - $robustUpper), $delta] | max) / $warnBudget | |
| elif ($confidence == "threshold_exceeded" and $delta < 0) then -(([0, ($robustLower - $currentRobustUpper), (-$delta)] | max) / $warnBudget) | |
| elif $delta > 0 then ([0, ($currentRobustLower - $robustUpper)] | max) / $warnBudget | |
| else -(([0, ($robustLower - $currentRobustUpper)] | max) / $warnBudget) | |
| end | |
| ) as $semanticImpactScore | |
| | ( | |
| if (policy_enabled($policy) != true) then "diagnostic" | |
| elif $semanticImpactScore == null then "unknown" | |
| elif $semanticImpactScore == 0 then "neutral" | |
| elif $semanticImpactScore >= ($failBudget / $warnBudget) then "fail_boundary" | |
| elif $semanticImpactScore >= 1 then "warn_boundary" | |
| elif $semanticImpactScore > 0 then "below_warn_boundary" | |
| else "improvement" | |
| end | |
| ) as $semanticImpactKind | |
| | {status:$status,current:$current,baseline:$baseline,delta:$delta,ratio:$ratio,budget:$b,gatePolicy:$policy,comparisonMode:$comparisonMode,gateable:$gateable,gateReason:$gateReason,confidence:$confidence,direction:$direction,semanticImpactScore:$semanticImpactScore,semanticImpactKind:$semanticImpactKind,semanticWarnBudget:$warnBudget,semanticFailBudget:$failBudget,baselineRobustLower:$robustLower,baselineRobustUpper:$robustUpper,baselineRobustTolerance:$robustTolerance,currentRobustLower:$currentRobustLower,currentRobustUpper:$currentRobustUpper,currentRobustTolerance:$currentRobustTolerance,withinBaselineRange:$withinBaselineRange,pairedSamples:$pairedSamples,evidenceDelta:$evidenceDelta,evidenceDeltaLower:$evidenceDeltaLower,evidenceDeltaUpper:$evidenceDeltaUpper,evidenceDeltaTolerance:$pairedDeltaTolerance,pairedEvidenceQuantile:$pairedEvidenceQuantile,pairedEvidenceProtocol:(if $comparisonMode == "paired" and ($pairedDeltaValues | length) > 0 then "paired-delta-quantile-v1" elif $comparisonMode == "paired" then "paired-summary-robust-band-v1" else null end)}; | |
| (observations_by_key($current[0]) | with_entries(.value = observation_stats(.value))) as $currentObs | |
| | (observations_by_key($baseline[0]) | with_entries(.value = observation_stats(.value))) as $baselineObs | |
| | ( | |
| $currentObs | |
| | to_entries | |
| | map( | |
| .key as $key | |
| | .value as $currentValue | |
| | ($baselineObs[$key] // null) as $baselineValue | |
| | ($currentValue.observation | observation_policy(.)) as $policy | |
| | ($policy.comparisonMode // (if ($currentValue.observation.measurementKind // $currentValue.measurementKind) == "deterministic" or ($currentValue.observation.unit // "") != "seconds" then "budget" elif ($currentValue.observation.measurementKind // $currentValue.measurementKind) == "diagnostic" then "diagnostic" else "historical" end)) as $comparisonMode | |
| | ($currentValue.pairedBaselineValue // null) as $pairedBaselineValue | |
| | (if $comparisonMode == "paired" and $pairedBaselineValue != null then { | |
| value: $pairedBaselineValue, | |
| min: $pairedBaselineValue, | |
| max: $pairedBaselineValue, | |
| p25: $pairedBaselineValue, | |
| p75: $pairedBaselineValue, | |
| p95: $pairedBaselineValue, | |
| mad: 0, | |
| sourceCount: $currentValue.pairedSampleCount | |
| } else $baselineValue end) as $effectiveBaselineValue | |
| | { | |
| key: $key, | |
| value: ( | |
| if $effectiveBaselineValue == null then | |
| { | |
| status: "missing_baseline", | |
| target: $currentValue.target, | |
| observation: $currentValue.observation, | |
| current: $currentValue.value, | |
| currentSamples: $currentValue.sampleCount, | |
| baselineSources: 0, | |
| gatePolicy: $policy, | |
| comparisonMode: $comparisonMode, | |
| gateable: false, | |
| gateReason: "missing_baseline", | |
| confidence: "missing_baseline", | |
| direction: "unknown" | |
| } | |
| else | |
| classify( | |
| $currentValue.observation.name; | |
| $currentValue.observation.unit; | |
| ($currentValue.observation.measurementKind // $currentValue.measurementKind); | |
| $policy; | |
| $currentValue.value; | |
| $currentValue.p25; | |
| $currentValue.p75; | |
| $currentValue.mad; | |
| $effectiveBaselineValue.value; | |
| $effectiveBaselineValue.min; | |
| $effectiveBaselineValue.max; | |
| $effectiveBaselineValue.p25; | |
| $effectiveBaselineValue.p75; | |
| $effectiveBaselineValue.p95; | |
| $effectiveBaselineValue.mad; | |
| $currentValue.sampleCount; | |
| $effectiveBaselineValue.sourceCount; | |
| $currentValue.pairedSampleCount; | |
| $currentValue.pairedDeltaMedianValue; | |
| $currentValue.pairedDeltaP25Value; | |
| $currentValue.pairedDeltaP75Value; | |
| $currentValue.pairedDeltaMadValue; | |
| ($currentValue.pairedDeltaSampleValues // []) | |
| ) + { | |
| target: $currentValue.target, | |
| observation: $currentValue.observation, | |
| currentSamples: $currentValue.sampleCount, | |
| baselineSources: $effectiveBaselineValue.sourceCount, | |
| baselineMin: $effectiveBaselineValue.min, | |
| baselineMax: $effectiveBaselineValue.max, | |
| baselineP25: $effectiveBaselineValue.p25, | |
| baselineP75: $effectiveBaselineValue.p75, | |
| baselineP95: $effectiveBaselineValue.p95 | |
| ,baselineMad: $effectiveBaselineValue.mad | |
| } | |
| end | |
| ) | |
| } | |
| ) | |
| | from_entries | |
| ) as $comparisons | |
| | ( | |
| if any($comparisons[]?; .status == "fail") then "fail" | |
| elif any($comparisons[]?; .status == "warn") then "warn" | |
| elif any($comparisons[]?; | |
| (if (.gatePolicy | has("enabled")) then .gatePolicy.enabled else true end) | |
| and (.gateReason == "missing_baseline" | |
| or .gateReason == "low_baseline_count" | |
| or .gateReason == "low_current_sample_count" | |
| or .gateReason == "low_paired_sample_count" | |
| or .gateReason == "missing_paired_delta") | |
| ) then "partial" | |
| else "pass" | |
| end | |
| ) as $status | |
| | ( | |
| [$comparisons[]?] | |
| | { | |
| enabledCount: (map(select((if (.gatePolicy | has("enabled")) then .gatePolicy.enabled else true end))) | length), | |
| gateableCount: (map(select(.gateable == true)) | length), | |
| missingBaselineCount: (map(select(.gateReason == "missing_baseline")) | length), | |
| lowBaselineCount: (map(select(.gateReason == "low_baseline_count")) | length), | |
| lowCurrentSampleCount: (map(select(.gateReason == "low_current_sample_count")) | length), | |
| lowPairedSampleCount: (map(select(.gateReason == "low_paired_sample_count")) | length), | |
| missingPairedDeltaCount: (map(select(.gateReason == "missing_paired_delta")) | length) | |
| } | |
| | . + { | |
| nonGateableCount: (.enabledCount - .gateableCount), | |
| enforceable: (.enabledCount == .gateableCount) | |
| } | |
| ) as $readiness | |
| | { | |
| schemaVersion:$schemaVersion, | |
| status:$status, | |
| mode:$mode, | |
| readiness:$readiness, | |
| currentDir:$currentDir, | |
| baselineDir:$baselineDir, | |
| comparisons:$comparisons | |
| } | |
| ' >"$comparison_file" | |
| baseline_provenance_file="$baseline_dir/baseline-provenance.json" | |
| if [ -f "$baseline_provenance_file" ]; then | |
| comparison_with_provenance="$(mktemp)" | |
| jq --slurpfile baselineProvenance "$baseline_provenance_file" \ | |
| '. + {baselineProvenance: ($baselineProvenance[0] // null)}' \ | |
| "$comparison_file" >"$comparison_with_provenance" | |
| mv "$comparison_with_provenance" "$comparison_file" | |
| fi | |
| status="$(jq -r '.status' "$comparison_file")" | |
| exit_code=0 | |
| case "$status:$mode" in | |
| fail:fail) | |
| echo "::error::CI measurement regression detected" | |
| exit_code=1 | |
| ;; | |
| fail:*|warn:*) | |
| echo "::warning::CI measurement regression threshold exceeded" | |
| ;; | |
| partial:*) | |
| echo "::notice::CI measurement comparison is partial because one or more enabled observations are not gateable" | |
| ;; | |
| esac | |
| if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then | |
| { | |
| echo "### ${CI_MEASUREMENT_PR_COMMENT_TITLE:-CI Measurements}" | |
| echo "" | |
| jq -r '"- Status: " + .status + "\n- Gate: " + (if .mode == "fail" then "enforced" elif .mode == "warn" then "advisory" elif .mode == "off" then "off" else (.mode // "unknown") end) + "\n- Baseline: " + .baselineDir' "$comparison_file" | |
| echo "" | |
| echo "| Status | Gate | Target | Observation | Current | Baseline | Delta | Ratio |" | |
| echo "| --- | --- | --- | --- | ---: | ---: | ---: | ---: |" | |
| jq -r ' | |
| .comparisons | |
| | to_entries | |
| | sort_by( | |
| if .value.status == "fail" then 0 | |
| elif .value.status == "warn" then 1 | |
| elif .value.status == "missing_baseline" then 2 | |
| else 3 | |
| end | |
| ) | |
| | .[:20] | |
| | .[] | |
| | .value as $v | |
| | [ | |
| $v.status, | |
| (if ($v.gateable // false) then "yes" else ($v.gateReason // "no") end), | |
| (($v.target.kind // "unknown") + "/" + ($v.target.name // "unknown") + "/" + ($v.target.system // "unknown")), | |
| ($v.observation.name // "unknown"), | |
| (($v.current // $v.observation.value // 0) | tostring), | |
| (($v.baseline // "") | tostring), | |
| (($v.delta // "") | tostring), | |
| (if $v.ratio == null or $v.ratio == "" then "" else (($v.ratio * 100 | round / 100) | tostring) end) | |
| ] | |
| | "| " + (map(gsub("\\|"; "\\\\|")) | join(" | ")) + " |" | |
| ' "$comparison_file" | |
| } >>"$GITHUB_STEP_SUMMARY" | |
| fi | |
| if [ "${CI_MEASUREMENT_PR_COMMENT_ENABLED:-false}" = "true" ]; then | |
| if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then | |
| echo "::notice::CI measurement PR comments are produced only by pull_request workflows; skipping comment for event ${GITHUB_EVENT_NAME:-unknown}" | |
| exit 0 | |
| fi | |
| can_render_pr_comment=true | |
| is_fork_pr=false | |
| if [ "${CI_MEASUREMENT_IS_FORK_PR:-}" = "1" ]; then | |
| echo "::notice::CI measurement PR comment skipped for fork pull request; summary and artifacts remain available" | |
| can_render_pr_comment=false | |
| is_fork_pr=true | |
| fi | |
| ensure_ci_measurement_tool() { | |
| tool_name="$1" | |
| nix_attr="$2" | |
| if command -v "$tool_name" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if ! command -v nix >/dev/null 2>&1; then | |
| return 1 | |
| fi | |
| if tool_out="$(nix build --no-link --print-out-paths "nixpkgs#$nix_attr" 2>/dev/null)"; then | |
| export PATH="$tool_out/bin:$PATH" | |
| fi | |
| command -v "$tool_name" >/dev/null 2>&1 | |
| } | |
| if ! ensure_ci_measurement_tool gh gh; then | |
| echo "::error::gh is not available; unable to publish required CI measurement PR comment" | |
| can_render_pr_comment=false | |
| fi | |
| if ! ensure_ci_measurement_tool node nodejs; then | |
| echo "::error::node is not available; unable to publish required CI measurement PR comment" | |
| can_render_pr_comment=false | |
| fi | |
| if ! command -v jq >/dev/null 2>&1; then | |
| if ensure_ci_measurement_tool jq jq; then | |
| : | |
| else | |
| echo "::error::jq is not available; unable to publish required CI measurement PR comment" | |
| can_render_pr_comment=false | |
| fi | |
| fi | |
| if [ -z "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then | |
| echo "::error::GH_TOKEN/GITHUB_TOKEN is not set; unable to publish required CI measurement PR comment" | |
| can_render_pr_comment=false | |
| fi | |
| event_path="${GITHUB_EVENT_PATH:-}" | |
| pr_number="" | |
| if [ "$can_render_pr_comment" = "true" ] && [ -n "$event_path" ] && [ -f "$event_path" ]; then | |
| pr_number="$(jq -r '.pull_request.number // empty' "$event_path")" | |
| fi | |
| if [ "$can_render_pr_comment" = "true" ] && [ -z "$pr_number" ]; then | |
| echo "::error::pull request number is unavailable; unable to publish required CI measurement PR comment" | |
| can_render_pr_comment=false | |
| fi | |
| if [ "$can_render_pr_comment" != "true" ]; then | |
| if [ "$is_fork_pr" != "true" ]; then | |
| exit 1 | |
| fi | |
| fi | |
| if [ "$can_render_pr_comment" = "true" ]; then | |
| repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}" | |
| comment_tmp_dir="$(mktemp -d)" | |
| comments_json="$comment_tmp_dir/comments.json" | |
| comment_body="$comment_tmp_dir/comment.md" | |
| comment_id_file="$comment_tmp_dir/comment-id.txt" | |
| chart_file="$comment_tmp_dir/perf-change-vs-baseline.svg" | |
| chart_dark_file="$comment_tmp_dir/perf-change-vs-baseline-dark.svg" | |
| chart_png_file="$comment_tmp_dir/perf-change-vs-baseline.png" | |
| chart_dark_png_file="$comment_tmp_dir/perf-change-vs-baseline-dark.png" | |
| renderer_script="$comment_tmp_dir/render-ci-measurement-comment.mjs" | |
| if ! gh api "repos/$repo/issues/$pr_number/comments" --paginate >"$comments_json"; then | |
| echo "::notice::unable to list PR comments; skipping CI measurement PR comment" | |
| can_render_pr_comment=false | |
| fi | |
| if [ "$can_render_pr_comment" = "true" ]; then | |
| asset_branch="${CI_MEASUREMENT_PR_COMMENT_ASSET_BRANCH:-ci-measurement-assets}" | |
| asset_title="$(printf '%s' "${CI_MEASUREMENT_PR_COMMENT_TITLE:-ci-measurements}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" | |
| if [ -z "$asset_title" ]; then | |
| asset_title="ci-measurements" | |
| fi | |
| asset_head_sha="${CI_MEASUREMENT_SUBJECT_SHA:-${GITHUB_HEAD_SHA:-${GITHUB_SHA:-unknown}}}" | |
| asset_run_id="${GITHUB_RUN_ID:-local}" | |
| asset_run_attempt="${GITHUB_RUN_ATTEMPT:-0}" | |
| asset_svg_path="ci-measurements/pr-$pr_number/${asset_head_sha}/run-${asset_run_id}-attempt-${asset_run_attempt}/${asset_title}.svg" | |
| asset_png_path="ci-measurements/pr-$pr_number/${asset_head_sha}/run-${asset_run_id}-attempt-${asset_run_attempt}/${asset_title}.png" | |
| asset_dark_png_path="ci-measurements/pr-$pr_number/${asset_head_sha}/run-${asset_run_id}-attempt-${asset_run_attempt}/${asset_title}-dark.png" | |
| public_asset_command="${CI_MEASUREMENT_PR_COMMENT_PUBLIC_ASSET_COMMAND:-}" | |
| repo_private="$(gh api "repos/$repo" --jq '.private // false' 2>/dev/null || printf 'true')" | |
| require_public_asset=false | |
| if [ "$repo_private" = "true" ]; then | |
| require_public_asset=true | |
| fi | |
| if [ "${GITHUB_SERVER_URL:-https://github.com}" = "https://github.com" ]; then | |
| github_raw_chart_url="https://raw.githubusercontent.com/$repo/$asset_branch/$asset_png_path" | |
| github_raw_chart_dark_url="https://raw.githubusercontent.com/$repo/$asset_branch/$asset_dark_png_path" | |
| github_raw_chart_source_url="https://raw.githubusercontent.com/$repo/$asset_branch/$asset_svg_path" | |
| else | |
| github_raw_chart_url="${GITHUB_SERVER_URL:-https://github.com}/$repo/raw/$asset_branch/$asset_png_path" | |
| github_raw_chart_dark_url="${GITHUB_SERVER_URL:-https://github.com}/$repo/raw/$asset_branch/$asset_dark_png_path" | |
| github_raw_chart_source_url="${GITHUB_SERVER_URL:-https://github.com}/$repo/raw/$asset_branch/$asset_svg_path" | |
| fi | |
| if [ "$repo_private" = "true" ]; then | |
| chart_url="" | |
| chart_dark_url="" | |
| chart_source_url="" | |
| else | |
| chart_url="$github_raw_chart_url" | |
| chart_dark_url="$github_raw_chart_dark_url" | |
| chart_source_url="$github_raw_chart_source_url" | |
| fi | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_URL="$chart_url" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_DARK_URL="$chart_dark_url" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_SOURCE_URL="$chart_source_url" | |
| cat > "$renderer_script" <<'EOF' | |
| import { readFileSync, writeFileSync } from 'node:fs' | |
| const [comparisonPath, commentsPath, bodyPath, commentIdPath, chartPath, chartDarkPath] = process.argv.slice(2) | |
| const title = process.env.CI_MEASUREMENT_PR_COMMENT_TITLE || 'CI Measurements' | |
| const maxRows = Number.parseInt(process.env.CI_MEASUREMENT_PR_COMMENT_MAX_ROWS || '10', 10) | |
| const maxHistory = Number.parseInt(process.env.CI_MEASUREMENT_PR_COMMENT_MAX_HISTORY || '20', 10) | |
| const repo = process.env.GITHUB_REPOSITORY || 'unknown' | |
| const runId = process.env.GITHUB_RUN_ID || '' | |
| const runAttempt = process.env.GITHUB_RUN_ATTEMPT || '' | |
| const sha = process.env.GITHUB_SHA || '' | |
| const headSha = process.env.CI_MEASUREMENT_SUBJECT_SHA || process.env.GITHUB_HEAD_SHA || sha | |
| const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com' | |
| const workflow = process.env.GITHUB_WORKFLOW || 'CI' | |
| const job = process.env.GITHUB_JOB || '' | |
| const chartUrl = process.env.CI_MEASUREMENT_PR_COMMENT_CHART_URL || '' | |
| const chartDarkUrl = process.env.CI_MEASUREMENT_PR_COMMENT_CHART_DARK_URL || '' | |
| const chartSourceUrl = process.env.CI_MEASUREMENT_PR_COMMENT_CHART_SOURCE_URL || '' | |
| const markerScope = (process.env.CI_MEASUREMENT_PR_COMMENT_MARKER || title) | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]+/g, '-') | |
| .replace(/^-+|-+$/g, '') || 'default' | |
| const marker = '<!-- ci-measurement-comment:managed:' + markerScope + ' -->' | |
| const legacyMarker = '<!-- ci-measurement-comment:managed -->' | |
| const statePrefix = '<!-- ci-measurement-comment:state\n' | |
| const stateSuffix = '\n-->' | |
| const stateTag = 'ci-measurement-comment-state' | |
| const schemaVersion = 1 | |
| const comparison = JSON.parse(readFileSync(comparisonPath, 'utf8')) | |
| const comments = JSON.parse(readFileSync(commentsPath, 'utf8')) | |
| if (!Array.isArray(comments)) throw new Error('comments response must be an array') | |
| const existing = comments.find((comment) => { | |
| if (typeof comment?.body !== 'string') return false | |
| return comment.body.includes(marker) || | |
| (comment.body.includes(legacyMarker) && comment.body.includes('## ' + title)) | |
| }) | |
| const extractState = (body) => { | |
| if (typeof body !== 'string') return undefined | |
| const start = body.indexOf(statePrefix) | |
| if (start === -1) return undefined | |
| const end = body.indexOf(stateSuffix, start + statePrefix.length) | |
| if (end === -1) return undefined | |
| try { | |
| const parsed = JSON.parse(body.slice(start + statePrefix.length, end)) | |
| if (parsed && parsed._tag === stateTag && Array.isArray(parsed.runs)) return parsed | |
| } catch { | |
| return undefined | |
| } | |
| return undefined | |
| } | |
| const formatNumber = (value) => { | |
| if (value === null || value === undefined || Number.isNaN(value)) return 'n/a' | |
| if (Number.isInteger(value)) return String(value) | |
| return String(Math.round(value * 1000) / 1000) | |
| } | |
| const formatValue = (value, unit) => { | |
| if (value === null || value === undefined) return 'n/a' | |
| if (unit === 'bytes') { | |
| if (value >= 1073741824) return formatNumber(Math.round((value / 1073741824) * 10) / 10) + ' GiB' | |
| if (value >= 1048576) return formatNumber(Math.round((value / 1048576) * 10) / 10) + ' MiB' | |
| if (value >= 1024) return formatNumber(Math.round((value / 1024) * 10) / 10) + ' KiB' | |
| return formatNumber(value) + ' B' | |
| } | |
| if (unit === 'seconds') return formatNumber(value) + ' s' | |
| return formatNumber(value) + (unit ? ' ' + unit : '') | |
| } | |
| const formatDelta = (value, unit) => { | |
| if (value === null || value === undefined) return 'n/a' | |
| const sign = value >= 0 ? '+' : '-' | |
| return sign + formatValue(Math.abs(value), unit) | |
| } | |
| const formatRatio = (value) => { | |
| if (value === null || value === undefined) return 'n/a' | |
| return formatNumber(Math.round((value - 1) * 1000) / 10) + '%' | |
| } | |
| const formatSemanticImpact = (value) => { | |
| if (value === null || value === undefined || Number.isNaN(value)) return 'n/a' | |
| if (Math.abs(value) < 0.005) return '0.00x' | |
| const sign = value > 0 ? '+' : '' | |
| return sign + formatNumber(Math.round(value * 100) / 100) + 'x' | |
| } | |
| const formatRowImpact = (row) => { | |
| if (row.confidence === 'diagnostic' || row.gateReason === 'disabled' || row.semanticImpactKind === 'diagnostic') { | |
| return 'diagnostic' | |
| } | |
| return formatSemanticImpact(row.semanticImpactScore) | |
| } | |
| const formatEvidence = (row) => { | |
| const unit = row.observation?.unit | |
| if (row.comparisonMode === 'paired' && typeof row.evidenceDeltaLower === 'number' && typeof row.evidenceDeltaUpper === 'number') { | |
| const quantile = typeof row.pairedEvidenceQuantile === 'number' | |
| ? Math.round(row.pairedEvidenceQuantile * 100) | |
| : 25 | |
| return (row.confidence || 'unknown') | |
| + '<br><sub>paired n=' + (row.pairedSamples ?? 0) | |
| + ', ' + quantile + '-' + (100 - quantile) + '% delta ' | |
| + formatValue(row.evidenceDeltaLower, unit) | |
| + ' - ' + formatValue(row.evidenceDeltaUpper, unit) | |
| + '</sub>' | |
| } | |
| return (row.confidence || 'unknown') + '<br><sub>baseline n=' + (row.baselineSources ?? 0) + ', current samples=' + (row.currentSamples ?? 1) + '</sub>' | |
| } | |
| const interpretation = (row) => { | |
| if (row.confidence === 'low_baseline_count') return { | |
| label: 'Needs more baseline', | |
| detail: 'Not enough compatible baseline runs to make this gate trustworthy.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'low_current_sample_count') return { | |
| label: 'Needs repeat', | |
| detail: 'Current run has too few successful measured samples.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'low_paired_sample_count') return { | |
| label: 'Needs paired evidence', | |
| detail: 'Wall-clock gates require same-run base/head samples before they can block merges.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'missing_paired_delta') return { | |
| label: 'Needs paired delta stats', | |
| detail: 'Wall-clock gates require per-pair delta statistics, not only paired medians.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'paired_uncertain') return { | |
| label: 'Uncertain wall-clock movement', | |
| detail: 'The paired median moved, but the paired delta band still crosses the configured budget.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'diagnostic') return { | |
| label: 'Diagnostic only', | |
| detail: 'Shown for investigation, but intentionally excluded from gating.', | |
| tone: 'diagnostic', | |
| color: '#a78bfa', | |
| } | |
| if (row.status === 'fail') return { | |
| label: 'Regression - blocks merge', | |
| detail: 'Worse than the configured fail threshold with enough samples.', | |
| tone: 'bad', | |
| color: '#ef4444', | |
| } | |
| if (row.status === 'warn') return { | |
| label: 'Regression - review', | |
| detail: 'Worse than the configured warning threshold.', | |
| tone: 'warn', | |
| color: '#f59e0b', | |
| } | |
| if (row.status === 'missing_baseline') return { | |
| label: 'No baseline yet', | |
| detail: 'Current value is measured, but no comparable baseline exists.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'noise_floor') return { | |
| label: 'Too small to matter', | |
| detail: 'The absolute change is below the noise floor for this metric.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'within_baseline_range') return { | |
| label: 'Historical range only', | |
| detail: 'Inside the full historical min/max range, but this range is not used to pass a gate.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.confidence === 'within_robust_band' || row.confidence === 'within_baseline_distribution') return { | |
| label: 'Within noise band', | |
| detail: 'Current and baseline robust noise bands overlap.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.direction === 'improved' && typeof row.semanticImpactScore === 'number' && row.semanticImpactScore <= -1) return { | |
| label: 'Meaningfully lower', | |
| detail: 'Lower than baseline by enough to cross the configured review threshold.', | |
| tone: 'good', | |
| color: '#10b981', | |
| } | |
| if (row.direction === 'improved') return { | |
| label: 'Slightly lower, ok', | |
| detail: 'Lower than baseline, but still inside the configured review budget.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| if (row.direction === 'regressed') return { | |
| label: 'Slightly higher, ok', | |
| detail: 'Higher than baseline but still inside the configured budget.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| return { | |
| label: 'Unchanged', | |
| detail: 'No meaningful movement from baseline.', | |
| tone: 'neutral', | |
| color: '#94a3b8', | |
| } | |
| } | |
| const formatGate = (row) => { | |
| if (row.gateable) return 'yes' | |
| const reason = row.gateReason || row.confidence || 'unknown' | |
| return 'no<br><sub>' + reason + '</sub>' | |
| } | |
| const escapeCell = (value) => String(value ?? '-').replaceAll('|', '\\|').replaceAll('\n', '<br>') | |
| const escapeXml = (value) => String(value) | |
| .replaceAll('&', '&') | |
| .replaceAll('<', '<') | |
| .replaceAll('>', '>') | |
| .replaceAll('"', '"') | |
| const humanProbe = (row) => { | |
| if (row.observation?.label) return row.observation.label | |
| const probe = row.observation?.dimensions?.probe | |
| const name = row.observation?.name || 'unknown' | |
| const labels = { | |
| shell_eval_traced: 'Shell eval with OTEL trace', | |
| shell_eval_warm: 'Warm shell eval', | |
| tasks_list: 'devenv tasks list', | |
| processes_help: 'devenv processes --help', | |
| task_pnpm_install: 'pnpm:install', | |
| task_genie_run: 'genie:run', | |
| task_check_quick: 'check:quick', | |
| task_check_quick_warm: 'Warm cached check:quick', | |
| task_check_quick_forced: 'Forced check:quick', | |
| } | |
| if (probe && labels[probe]) return labels[probe] | |
| if (name.startsWith('devenv.') && name.endsWith('.duration')) { | |
| return name.slice('devenv.'.length, -'.duration'.length).replaceAll('_', ' ') | |
| } | |
| return name | |
| } | |
| const semanticPath = (row) => { | |
| const parts = semanticSegments(row) | |
| return parts.length > 0 ? parts.join(' / ') : '-' | |
| } | |
| const semanticSegments = (row) => { | |
| const parts = [ | |
| ...(Array.isArray(row.target?.path) ? row.target.path : []), | |
| row.target?.group, | |
| ...(Array.isArray(row.observation?.path) ? row.observation.path : []), | |
| row.observation?.group, | |
| ].filter((value) => typeof value === 'string' && value.length > 0) | |
| const seen = new Set() | |
| const unique = parts.filter((part) => { | |
| if (seen.has(part)) return false | |
| seen.add(part) | |
| return true | |
| }) | |
| if (unique.length > 0) return unique | |
| const kind = row.target?.kind || row.observation?.measurementKind || 'measurements' | |
| if (kind === 'devenv') return ['performance', 'devenv'] | |
| if (kind === 'nix-closure') return ['nix', 'closures'] | |
| return [String(kind)] | |
| } | |
| const semanticGroupSegments = (row) => { | |
| const segments = semanticSegments(row) | |
| if (segments.length <= 1) return segments | |
| const targetKind = row.target?.kind | |
| if (targetKind === 'devenv') return segments.slice(0, Math.min(2, segments.length)) | |
| if (targetKind === 'nix-closure') return segments.slice(0, Math.min(3, segments.length)) | |
| if (targetKind === 'source-shape') return segments.slice(0, Math.min(2, segments.length)) | |
| return segments.slice(0, Math.min(2, segments.length)) | |
| } | |
| const semanticGroupLabel = (row) => { | |
| const segments = semanticGroupSegments(row) | |
| return segments.length > 0 ? segments.join(' / ') : 'measurements' | |
| } | |
| const groupRows = (rows) => { | |
| const groups = new Map() | |
| for (const row of rows) { | |
| const label = semanticGroupLabel(row) | |
| const existing = groups.get(label) | |
| if (existing) existing.rows.push(row) | |
| else groups.set(label, { label, rows: [row] }) | |
| } | |
| return Array.from(groups.values()).sort((left, right) => { | |
| const leftRank = Math.min(...left.rows.map(rank)) | |
| const rightRank = Math.min(...right.rows.map(rank)) | |
| if (leftRank !== rightRank) return leftRank - rightRank | |
| const leftImpact = Math.max(...left.rows.map((row) => Math.abs(row.semanticImpactScore || 0))) | |
| const rightImpact = Math.max(...right.rows.map((row) => Math.abs(row.semanticImpactScore || 0))) | |
| if (rightImpact !== leftImpact) return rightImpact - leftImpact | |
| return left.label.localeCompare(right.label) | |
| }) | |
| } | |
| const chartProbe = (row) => { | |
| if (row.observation?.label) return row.observation.label | |
| const probe = row.observation?.dimensions?.probe | |
| const labels = { | |
| shell_eval_traced: 'Shell eval with OTEL trace', | |
| shell_eval_warm: 'Warm shell eval', | |
| tasks_list: 'devenv tasks list', | |
| processes_help: 'processes --help', | |
| task_pnpm_install: 'pnpm:install', | |
| task_genie_run: 'genie:run', | |
| task_check_quick: 'check:quick', | |
| task_check_quick_warm: 'Warm cached check:quick', | |
| task_check_quick_forced: 'Forced check:quick', | |
| } | |
| if (probe && labels[probe]) return labels[probe] | |
| return humanProbe(row) | |
| } | |
| const dimensions = (row) => { | |
| const entries = Object.entries(row.observation?.dimensions || {}) | |
| if (entries.length === 0) return '-' | |
| return entries | |
| .sort(([left], [right]) => left.localeCompare(right)) | |
| .map(([key, value]) => key + '=' + String(value)) | |
| .join('<br>') | |
| } | |
| const rank = (row) => { | |
| if (row.status === 'fail') return 0 | |
| if (row.status === 'warn') return 1 | |
| if (row.status === 'missing_baseline') return 3 | |
| return 2 | |
| } | |
| const allRows = Object.values(comparison.comparisons || {}).sort((left, right) => { | |
| const byRank = rank(left) - rank(right) | |
| if (byRank !== 0) return byRank | |
| const leftImpact = typeof left.semanticImpactScore === 'number' ? Math.abs(left.semanticImpactScore) : 0 | |
| const rightImpact = typeof right.semanticImpactScore === 'number' ? Math.abs(right.semanticImpactScore) : 0 | |
| if (rightImpact !== leftImpact) return rightImpact - leftImpact | |
| const leftDelta = typeof left.delta === 'number' ? Math.abs(left.delta) : 0 | |
| const rightDelta = typeof right.delta === 'number' ? Math.abs(right.delta) : 0 | |
| if (rightDelta !== leftDelta) return rightDelta - leftDelta | |
| return humanProbe(left).localeCompare(humanProbe(right)) | |
| }) | |
| const protocolLabel = (() => { | |
| const protocols = new Set( | |
| allRows | |
| .map((row) => row.observation?.dimensions?.measurementProtocol) | |
| .filter((value) => typeof value === 'string' && value.length > 0), | |
| ) | |
| return protocols.size > 0 ? Array.from(protocols).join(', ') : 'legacy' | |
| })() | |
| const visibleLimit = Number.isFinite(maxRows) && maxRows > 0 ? maxRows : 10 | |
| const comparableRows = allRows.filter((row) => typeof row.baseline === 'number') | |
| const hasComparableBaseline = comparableRows.length > 0 | |
| const isDiagnosticRow = (row) => | |
| row.status === 'missing_baseline' || | |
| row.confidence === 'diagnostic' || | |
| row.gateReason === 'disabled' || | |
| row.semanticImpactKind === 'diagnostic' || | |
| (!row.gateable && typeof row.baseline !== 'number') | |
| const isZeroImpactRow = (row) => | |
| typeof row.semanticImpactScore === 'number' && | |
| !Number.isNaN(row.semanticImpactScore) && | |
| Math.abs(row.semanticImpactScore) < 0.005 | |
| const actionableComparableRows = comparableRows.filter((row) => !isDiagnosticRow(row)) | |
| const visibleRows = (hasComparableBaseline | |
| ? actionableComparableRows | |
| : allRows.filter((row) => !isDiagnosticRow(row)).sort((left, right) => (right.current || 0) - (left.current || 0)) | |
| ).slice(0, visibleLimit) | |
| const nonZeroImpactRows = actionableComparableRows.filter((row) => !isZeroImpactRow(row)) | |
| const zeroImpactRows = actionableComparableRows.filter(isZeroImpactRow) | |
| const visibleNonZeroImpactRows = nonZeroImpactRows.slice(0, visibleLimit) | |
| const diagnosticRows = allRows.filter(isDiagnosticRow) | |
| const baselineToCurrent = (row) => { | |
| const unit = row.observation?.unit | |
| return formatValue(row.baseline, unit) + ' -> ' + formatValue(row.current, unit) | |
| } | |
| const rawChange = (row) => { | |
| const unit = row.observation?.unit | |
| return formatDelta(row.delta, unit) + ' / ' + formatRatio(row.ratio) | |
| } | |
| const confidenceSummary = (row) => { | |
| const unit = row.observation?.unit | |
| if (row.comparisonMode === 'paired' && typeof row.evidenceDeltaLower === 'number' && typeof row.evidenceDeltaUpper === 'number') { | |
| const quantile = typeof row.pairedEvidenceQuantile === 'number' | |
| ? Math.round(row.pairedEvidenceQuantile * 100) | |
| : 25 | |
| return 'paired n=' + (row.pairedSamples ?? 0) | |
| + ', ' + quantile + '-' + (100 - quantile) + '% delta ' | |
| + formatValue(row.evidenceDeltaLower, unit) | |
| + '..' + formatValue(row.evidenceDeltaUpper, unit) | |
| } | |
| return (row.confidence || 'unknown') + ', baseline n=' + (row.baselineSources ?? 0) + ', current n=' + (row.currentSamples ?? 1) | |
| } | |
| const scanDecision = (row) => { | |
| if (row.status === 'fail') return 'regression blocks' | |
| if (row.status === 'warn') return 'regression review' | |
| if (row.status === 'missing_baseline') return 'needs baseline' | |
| if (row.direction === 'improved') return 'faster' | |
| if (row.direction === 'regressed') return 'no material impact' | |
| return 'unchanged' | |
| } | |
| const scanTable = (rows) => { | |
| if (rows.length === 0) return 'No non-zero actionable measurement impact detected.' | |
| return [ | |
| '| What changed? | Group | Probe | Baseline -> current | Raw change | Impact | Confidence |', | |
| '| --- | --- | --- | --- | ---: | ---: | --- |', | |
| ...rows.map((row) => { | |
| return '| ' + [ | |
| scanDecision(row), | |
| semanticGroupLabel(row), | |
| humanProbe(row), | |
| baselineToCurrent(row), | |
| rawChange(row), | |
| formatRowImpact(row), | |
| confidenceSummary(row), | |
| ].map(escapeCell).join(' | ') + ' |' | |
| }), | |
| ].join('\n') | |
| } | |
| const groupedScanTables = (rows) => { | |
| if (rows.length === 0) return 'No non-zero actionable measurement impact detected.' | |
| return groupRows(rows).map((group) => [ | |
| '### ' + group.label, | |
| '', | |
| scanTable(group.rows), | |
| ].join('\n')).join('\n\n') | |
| } | |
| const zeroImpactTable = (rows) => { | |
| if (rows.length === 0) return 'No zero-impact measurements.' | |
| return [ | |
| '| Group | Probe | Baseline -> current | Raw change | Impact | Gate | Evidence | Why hidden |', | |
| '| --- | --- | --- | ---: | ---: | --- | --- | --- |', | |
| ...rows.map((row) => { | |
| const meaning = interpretation(row) | |
| return '| ' + [ | |
| semanticGroupLabel(row), | |
| humanProbe(row), | |
| baselineToCurrent(row), | |
| rawChange(row), | |
| formatRowImpact(row), | |
| row.gateable ? 'yes' : (row.gateReason || 'no'), | |
| confidenceSummary(row), | |
| meaning.label, | |
| ].map(escapeCell).join(' | ') + ' |' | |
| }), | |
| ].join('\n') | |
| } | |
| const groupedZeroImpactTables = (rows) => { | |
| if (rows.length === 0) return 'No zero-impact measurements.' | |
| return groupRows(rows).map((group) => [ | |
| '### ' + group.label, | |
| '', | |
| zeroImpactTable(group.rows), | |
| ].join('\n')).join('\n\n') | |
| } | |
| const diagnosticTable = (rows) => { | |
| if (rows.length === 0) return 'No diagnostic or ungated measurements.' | |
| return [ | |
| '| Group | Probe | Current | Baseline | Impact | Gate | Reason | Evidence |', | |
| '| --- | --- | ---: | ---: | ---: | --- | --- | --- |', | |
| ...rows.map((row) => { | |
| return '| ' + [ | |
| semanticGroupLabel(row), | |
| humanProbe(row), | |
| formatValue(row.current, row.observation?.unit), | |
| formatValue(row.baseline, row.observation?.unit), | |
| formatRowImpact(row), | |
| row.gateable ? 'yes' : (row.gateReason || row.status || 'no'), | |
| interpretation(row).label, | |
| confidenceSummary(row), | |
| ].map(escapeCell).join(' | ') + ' |' | |
| }), | |
| ].join('\n') | |
| } | |
| const groupedDiagnosticTables = (rows) => { | |
| if (rows.length === 0) return 'No diagnostic or ungated measurements.' | |
| return groupRows(rows).map((group) => [ | |
| '### ' + group.label, | |
| '', | |
| diagnosticTable(group.rows), | |
| ].join('\n')).join('\n\n') | |
| } | |
| const comparisonTable = (rows) => { | |
| if (rows.length === 0) return 'No measurement regressions detected.' | |
| return [ | |
| '| Group | Measurement | Baseline | Current | Raw change | Impact | Meaning | Gate | Evidence |', | |
| '| --- | --- | ---: | ---: | ---: | ---: | --- | --- | --- |', | |
| ...rows.map((row) => { | |
| const unit = row.observation?.unit | |
| const baselineRange = typeof row.baselineRobustLower === 'number' && typeof row.baselineRobustUpper === 'number' && row.baselineRobustLower !== row.baselineRobustUpper | |
| ? '<br><sub>noise band ' + formatValue(row.baselineRobustLower, unit) + ' - ' + formatValue(row.baselineRobustUpper, unit) + '</sub>' | |
| : typeof row.baselineMin === 'number' && typeof row.baselineMax === 'number' && row.baselineMin !== row.baselineMax | |
| ? '<br><sub>range ' + formatValue(row.baselineMin, unit) + ' - ' + formatValue(row.baselineMax, unit) + '</sub>' | |
| : '' | |
| const meaning = interpretation(row) | |
| return '| ' + [ | |
| semanticPath(row), | |
| humanProbe(row), | |
| formatValue(row.baseline, unit) + baselineRange, | |
| formatValue(row.current, unit), | |
| formatDelta(row.delta, unit) + ' / ' + formatRatio(row.ratio), | |
| formatRowImpact(row), | |
| meaning.label + '<br><sub>' + meaning.detail + '</sub>', | |
| formatGate(row), | |
| formatEvidence(row), | |
| ].map(escapeCell).join(' | ') + ' |' | |
| }), | |
| ].join('\n') | |
| } | |
| const currentOnlyTable = (rows) => { | |
| if (rows.length === 0) return 'No current measurements found.' | |
| return [ | |
| '| Group | Measurement | Current |', | |
| '| --- | --- | ---: |', | |
| ...rows.map((row) => { | |
| return '| ' + [semanticPath(row), humanProbe(row), formatValue(row.current, row.observation?.unit)].map(escapeCell).join(' | ') + ' |' | |
| }), | |
| ].join('\n') | |
| } | |
| const allMeasurementsTable = (rows) => { | |
| if (rows.length === 0) return 'No measurement regressions detected.' | |
| return [ | |
| '| Status | Gate | Target | Observation | Dimensions | Baseline | Current | Delta | Ratio | Impact |', | |
| '| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |', | |
| ...rows.map((row) => { | |
| const unit = row.observation?.unit | |
| return '| ' + [ | |
| row.status, | |
| row.gateable ? 'yes' : (row.gateReason || 'no'), | |
| row.target?.label || row.target?.name || 'unknown', | |
| row.observation?.label || row.observation?.name || 'unknown', | |
| dimensions(row), | |
| formatValue(row.baseline, unit), | |
| formatValue(row.current, unit), | |
| formatDelta(row.delta, unit), | |
| formatRatio(row.ratio), | |
| formatRowImpact(row), | |
| ].map(escapeCell).join(' | ') + ' |' | |
| }), | |
| ].join('\n') | |
| } | |
| const sourceMeasurement = (row) => ({ | |
| id: row.observation?.dimensions?.probe || row.observation?.name || humanProbe(row), | |
| label: humanProbe(row), | |
| group: semanticGroupLabel(row), | |
| path: semanticSegments(row), | |
| groupPath: semanticGroupSegments(row), | |
| status: row.status, | |
| direction: row.direction, | |
| gateable: row.gateable, | |
| gateReason: row.gateReason, | |
| confidence: row.confidence, | |
| comparisonMode: row.comparisonMode, | |
| unit: row.observation?.unit, | |
| baseline: row.baseline ?? null, | |
| current: row.current ?? null, | |
| delta: row.delta ?? null, | |
| ratio: row.ratio ?? null, | |
| semanticImpactScore: row.semanticImpactScore ?? null, | |
| semanticImpactKind: row.semanticImpactKind ?? null, | |
| baselineSources: row.baselineSources ?? null, | |
| currentSamples: row.currentSamples ?? null, | |
| pairedSamples: row.pairedSamples ?? null, | |
| evidenceDeltaLower: row.evidenceDeltaLower ?? null, | |
| evidenceDeltaUpper: row.evidenceDeltaUpper ?? null, | |
| pairedEvidenceQuantile: row.pairedEvidenceQuantile ?? null, | |
| dimensions: row.observation?.dimensions || {}, | |
| }) | |
| const truncate = (value, maxLength) => { | |
| const text = String(value) | |
| if (text.length <= maxLength) return text | |
| if (maxLength <= 1) return text.slice(0, maxLength) | |
| return text.slice(0, Math.max(0, maxLength - 3)) + '...' | |
| } | |
| const renderPerfChangeSvg = (rows, theme = 'adaptive') => { | |
| const chartRows = rows | |
| .filter((row) => typeof row.current === 'number' && typeof row.baseline === 'number') | |
| .filter((row) => row.gateable === true) | |
| .filter((row) => typeof row.semanticImpactScore === 'number') | |
| .sort((left, right) => (left.semanticImpactScore || 0) - (right.semanticImpactScore || 0)) | |
| .slice(0, visibleLimit) | |
| if (chartRows.length === 0) return '' | |
| const impactScores = chartRows.map((row) => row.semanticImpactScore || 0) | |
| const minImpact = Math.min(-1, ...impactScores) | |
| const maxImpact = Math.max(1, ...impactScores) | |
| const lower = Math.floor(minImpact) | |
| const upper = Math.ceil(maxImpact) | |
| const span = upper - lower || 1 | |
| const width = 1040 | |
| const rowHeight = 46 | |
| const height = 112 + chartRows.length * rowHeight + 34 | |
| const labelX = 230 | |
| const plotX = 252 | |
| const plotWidth = 320 | |
| const impactX = 596 | |
| const nominalX = 672 | |
| const meaningX = 804 | |
| const topY = 92 | |
| const barHeight = 18 | |
| const zeroX = plotX + ((0 - lower) / span) * plotWidth | |
| const themeCss = theme === 'dark' | |
| ? [ | |
| ' .chart-bg { fill: #0d1117; }', | |
| ' .chart-border { fill: none; stroke: #30363d; }', | |
| ' .chart-title { fill: #f0f6fc; }', | |
| ' .chart-muted { fill: #8b949e; }', | |
| ' .chart-axis { stroke: #8b949e; }', | |
| ' .chart-label { fill: #c9d1d9; }', | |
| ' .chart-value { fill: #8b949e; }', | |
| ' .chart-track { fill: #21262d; }', | |
| ] | |
| : [ | |
| ' .chart-bg { fill: #ffffff; }', | |
| ' .chart-border { fill: none; stroke: #d0d7de; }', | |
| ' .chart-title { fill: #24292f; }', | |
| ' .chart-muted { fill: #57606a; }', | |
| ' .chart-axis { stroke: #8c959f; }', | |
| ' .chart-label { fill: #24292f; }', | |
| ' .chart-value { fill: #57606a; }', | |
| ' .chart-track { fill: #f6f8fa; }', | |
| ...(theme === 'adaptive' | |
| ? [ | |
| ' @media (prefers-color-scheme: dark) {', | |
| ' .chart-bg { fill: #0d1117; }', | |
| ' .chart-border { stroke: #30363d; }', | |
| ' .chart-title { fill: #f0f6fc; }', | |
| ' .chart-muted { fill: #8b949e; }', | |
| ' .chart-axis { stroke: #8b949e; }', | |
| ' .chart-label { fill: #c9d1d9; }', | |
| ' .chart-value { fill: #8b949e; }', | |
| ' .chart-track { fill: #21262d; }', | |
| ' }', | |
| ] | |
| : []), | |
| ] | |
| const svg = [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '" viewBox="0 0 ' + width + ' ' + height + '">', | |
| '<style>', | |
| ...themeCss, | |
| '</style>', | |
| '<rect class="chart-bg" width="' + width + '" height="' + height + '" rx="8"/>', | |
| '<rect class="chart-border" x="0.5" y="0.5" width="' + (width - 1) + '" height="' + (height - 1) + '" rx="7.5"/>', | |
| '<text class="chart-title" x="' + width / 2 + '" y="28" text-anchor="middle" font-family="DejaVu Sans" font-size="16" font-weight="700">Actionable measurement impact</text>', | |
| '<text class="chart-muted" x="' + width / 2 + '" y="48" text-anchor="middle" font-family="DejaVu Sans" font-size="11">0 means no actionable PR impact; 1x reaches the warning budget.</text>', | |
| '<text x="' + plotX + '" y="72" font-family="DejaVu Sans" font-size="11" fill="#059669">improved</text>', | |
| '<text x="' + (plotX + plotWidth) + '" y="72" text-anchor="end" font-family="DejaVu Sans" font-size="11" fill="#dc2626">regressed</text>', | |
| '<text class="chart-muted" x="' + impactX + '" y="72" font-family="DejaVu Sans" font-size="11">impact</text>', | |
| '<text class="chart-muted" x="' + nominalX + '" y="72" font-family="DejaVu Sans" font-size="11">baseline -> current</text>', | |
| '<text class="chart-muted" x="' + meaningX + '" y="72" font-family="DejaVu Sans" font-size="11">meaning</text>', | |
| '<line class="chart-axis" x1="' + zeroX.toFixed(1) + '" y1="82" x2="' + zeroX.toFixed(1) + '" y2="' + (height - 34) + '" stroke-width="1.1" opacity="0.9"/>', | |
| ] | |
| for (const [index, row] of chartRows.entries()) { | |
| const impact = row.semanticImpactScore || 0 | |
| const y = topY + index * rowHeight | |
| const valueWidth = Math.max(2, Math.abs(impact) / span * plotWidth) | |
| const x = impact < 0 ? zeroX - valueWidth : zeroX | |
| const meaning = interpretation(row) | |
| const color = meaning.color | |
| const formattedImpact = formatSemanticImpact(impact) | |
| const label = chartProbe(row) | |
| const nominal = formatValue(row.baseline, row.observation?.unit).replaceAll(' ', '') + ' -> ' + formatValue(row.current, row.observation?.unit).replaceAll(' ', '') | |
| const barOpacity = meaning.tone === 'neutral' ? '0.65' : '1' | |
| const dash = meaning.tone === 'diagnostic' ? ' stroke-dasharray="3 3"' : '' | |
| svg.push( | |
| '<text class="chart-label" x="' + labelX + '" y="' + (y + 13) + '" text-anchor="end" font-family="DejaVu Sans" font-size="12"><title>' + escapeXml(label) + '</title>' + escapeXml(truncate(label, 28)) + '</text>', | |
| '<rect class="chart-track" x="' + plotX + '" y="' + y + '" width="' + plotWidth + '" height="' + barHeight + '" rx="5"/>', | |
| '<rect x="' + x.toFixed(1) + '" y="' + y + '" width="' + valueWidth.toFixed(1) + '" height="' + barHeight + '" rx="5" fill="' + color + '" opacity="' + barOpacity + '"' + dash + '/>', | |
| '<text x="' + impactX + '" y="' + (y + 13) + '" font-family="DejaVu Sans" font-size="12" font-weight="700" fill="' + color + '">' + escapeXml(formattedImpact) + '</text>', | |
| '<text class="chart-value" x="' + nominalX + '" y="' + (y + 13) + '" font-family="DejaVu Sans" font-size="11"><title>' + escapeXml(nominal) + '</title>' + escapeXml(truncate(nominal, 21)) + '</text>', | |
| '<text x="' + meaningX + '" y="' + (y + 13) + '" font-family="DejaVu Sans" font-size="11" font-weight="600" fill="' + color + '"><title>' + escapeXml(meaning.detail) + '</title>' + escapeXml(truncate(meaning.label, 30)) + '</text>', | |
| ) | |
| } | |
| svg.push( | |
| '<text class="chart-muted" x="' + zeroX.toFixed(1) + '" y="' + (height - 16) + '" text-anchor="middle" font-family="DejaVu Sans" font-size="10">0</text>', | |
| '</svg>', | |
| ) | |
| return svg.join('\n') | |
| } | |
| const statusWord = comparison.status || 'unknown' | |
| const readiness = comparison.readiness || {} | |
| const readinessLabel = readiness.enforceable | |
| ? 'enforceable' | |
| : 'partial (' + (readiness.gateableCount ?? 0) + '/' + (readiness.enabledCount ?? 0) + ' enabled observations gateable)' | |
| const runUrl = runId ? serverUrl + '/' + repo + '/actions/runs/' + runId : undefined | |
| const shortSha = (headSha || sha || 'unknown').slice(0, 7) | |
| const existingState = extractState(existing?.body) | |
| const currentRun = { | |
| commitSha: headSha || sha || 'unknown', | |
| shortSha, | |
| generatedAt: new Date().toISOString(), | |
| status: statusWord, | |
| mode: comparison.mode || 'unknown', | |
| runUrl, | |
| runAttempt, | |
| workflow, | |
| job, | |
| visibleRows: visibleRows.map((row) => ({ | |
| status: row.status, | |
| target: row.target?.label || row.target?.name || 'unknown', | |
| observation: row.observation?.label || row.observation?.name || 'unknown', | |
| meaning: interpretation(row).label, | |
| dimensions: dimensions(row).replaceAll('<br>', ', '), | |
| baseline: formatValue(row.baseline, row.observation?.unit), | |
| current: formatValue(row.current, row.observation?.unit), | |
| delta: formatDelta(row.delta, row.observation?.unit), | |
| ratio: formatRatio(row.ratio), | |
| impact: formatSemanticImpact(row.semanticImpactScore), | |
| })), | |
| } | |
| const hasComparableHistory = (run) => Array.isArray(run.visibleRows) && run.visibleRows.some((row) => | |
| row.status !== 'missing_baseline' && | |
| row.baseline !== 'n/a' && | |
| row.ratio !== 'n/a' | |
| ) | |
| const previousRuns = (existingState?.runs || []).filter((run) => run.commitSha !== currentRun.commitSha && hasComparableHistory(run)) | |
| const historyLimit = Number.isFinite(maxHistory) && maxHistory > 0 ? maxHistory : 20 | |
| const state = { _tag: stateTag, schemaVersion, title, runs: [currentRun, ...previousRuns].slice(0, historyLimit) } | |
| const gateModeLabel = (mode) => { | |
| if (mode === 'fail') return 'enforced' | |
| if (mode === 'warn') return 'advisory' | |
| if (mode === 'off') return 'off' | |
| return mode || 'unknown' | |
| } | |
| const historyRows = state.runs.slice(1).map((run) => { | |
| const link = run.runUrl ? '[' + run.shortSha + '](' + run.runUrl + ')' : run.shortSha | |
| const top = Array.isArray(run.visibleRows) && run.visibleRows.length > 0 | |
| ? run.visibleRows.slice(0, 3).map((row) => (row.meaning || row.status) + ' ' + row.target + ' ' + row.observation + ' ' + row.delta + ' / ' + row.ratio).join('<br>') | |
| : 'No regressions' | |
| return '| ' + [link, run.status, gateModeLabel(run.mode), top].map(escapeCell).join(' | ') + ' |' | |
| }) | |
| const runLink = runUrl ? '[workflow run](' + runUrl + ')' : 'workflow run unavailable' | |
| const baselineProvenance = comparison.baselineProvenance | |
| const baselineLabel = baselineProvenance?.runId | |
| ? '[main run ' + baselineProvenance.runId + '](' + serverUrl + '/' + repo + '/actions/runs/' + baselineProvenance.runId + ')' + | |
| (Array.isArray(baselineProvenance.runs) && baselineProvenance.runs.length > 1 ? ' + ' + (baselineProvenance.runs.length - 1) + ' older baseline runs' : '') | |
| : 'not available' | |
| const sourceOfTruth = { | |
| schemaVersion, | |
| title, | |
| status: statusWord, | |
| gate: gateModeLabel(comparison.mode), | |
| readiness: readinessLabel, | |
| commit: { | |
| shortSha, | |
| sha: headSha || sha || 'unknown', | |
| }, | |
| run: { | |
| id: runId || null, | |
| attempt: runAttempt || null, | |
| url: runUrl || null, | |
| }, | |
| baseline: baselineProvenance || null, | |
| protocol: protocolLabel, | |
| chart: { | |
| meaning: 'semantic-impact', | |
| zeroImpactMeaning: 'no actionable PR impact after budgets, noise floor, and robust evidence checks', | |
| svg: chartSourceUrl || null, | |
| lightPng: chartUrl || null, | |
| darkPng: chartDarkUrl || null, | |
| }, | |
| measurements: allRows.map(sourceMeasurement), | |
| } | |
| const chartSvg = hasComparableBaseline && visibleRows.length > 0 ? renderPerfChangeSvg(visibleRows) : '' | |
| const chartDarkSvg = hasComparableBaseline && visibleRows.length > 0 ? renderPerfChangeSvg(visibleRows, 'dark') : '' | |
| if (chartPath && chartSvg) writeFileSync(chartPath, chartSvg) | |
| if (chartDarkPath && chartDarkSvg) writeFileSync(chartDarkPath, chartDarkSvg) | |
| const chartImageMarkdown = chartUrl && chartSvg | |
| ? (chartDarkUrl | |
| ? '<picture>\n' + | |
| ' <source media="(prefers-color-scheme: dark)" srcset="' + chartDarkUrl + '">\n' + | |
| ' <source media="(prefers-color-scheme: light)" srcset="' + chartUrl + '">\n' + | |
| ' <img alt="Measurement change vs baseline chart" src="' + chartUrl + '">\n' + | |
| '</picture>' | |
| : '') | |
| : '' | |
| const chartMarkdown = chartImageMarkdown | |
| ? chartImageMarkdown + | |
| (chartSourceUrl ? '\n\n[SVG source](' + chartSourceUrl + ')' : '') | |
| : '' | |
| const regressionCount = allRows.filter((row) => row.status === 'fail' || row.status === 'warn').length | |
| const improvementCount = comparableRows.filter((row) => row.direction === 'improved' && !isZeroImpactRow(row)).length | |
| const neutralCount = zeroImpactRows.length + diagnosticRows.length | |
| const humanSummary = hasComparableBaseline | |
| ? regressionCount > 0 | |
| ? String(regressionCount) + ' regression' + (regressionCount === 1 ? '' : 's') + ' need review.' | |
| : improvementCount > 0 | |
| ? 'No regressions. ' + String(improvementCount) + ' probe' + (improvementCount === 1 ? '' : 's') + ' got faster; ' + String(neutralCount) + ' neutral or ungated row' + (neutralCount === 1 ? '' : 's') + ' are collapsed below.' | |
| : 'No regressions. Comparable movement is below the semantic impact threshold; neutral rows are collapsed below.' | |
| : 'No compatible baseline was available, so this run shows current measurements only.' | |
| const summaryLines = [ | |
| '## ' + title, | |
| '', | |
| '**' + statusWord + '** - ' + gateModeLabel(comparison.mode) + ' gate - readiness <code>' + readinessLabel + '</code> - commit <code>' + shortSha + '</code> - protocol <code>' + protocolLabel + '</code>', | |
| '', | |
| '> ' + humanSummary, | |
| '', | |
| chartMarkdown, | |
| '', | |
| hasComparableBaseline | |
| ? groupedScanTables(visibleNonZeroImpactRows) | |
| : currentOnlyTable(visibleRows), | |
| ] | |
| if (hasComparableBaseline && zeroImpactRows.length > 0) { | |
| summaryLines.push( | |
| '', | |
| '<details>', | |
| '<summary>Unchanged / 0-impact measurements (' + zeroImpactRows.length + ')</summary>', | |
| '', | |
| 'These rows had compatible baseline data, but their semantic impact rounded to 0.00x because the movement was below the configured budget, below the noise floor, or inside the robust noise band.', | |
| '', | |
| groupedZeroImpactTables(zeroImpactRows), | |
| '', | |
| '</details>', | |
| ) | |
| } | |
| if (diagnosticRows.length > 0) { | |
| summaryLines.push( | |
| '', | |
| '<details>', | |
| '<summary>Diagnostic / ungated measurements (' + diagnosticRows.length + ')</summary>', | |
| '', | |
| groupedDiagnosticTables(diagnosticRows), | |
| '', | |
| '</details>', | |
| ) | |
| } | |
| summaryLines.push( | |
| '', | |
| '<details>', | |
| '<summary>All measurements</summary>', | |
| '', | |
| allMeasurementsTable(allRows), | |
| '', | |
| '</details>', | |
| ) | |
| if (historyRows.length > 0) { | |
| summaryLines.push( | |
| '', | |
| '<details>', | |
| '<summary>Previous runs</summary>', | |
| '', | |
| '| Commit | Status | Gate | Top changes |', | |
| '| --- | --- | --- | --- |', | |
| ...historyRows, | |
| '', | |
| '</details>', | |
| ) | |
| } | |
| summaryLines.push( | |
| '', | |
| '<details>', | |
| '<summary>Source-of-truth JSON</summary>', | |
| '', | |
| '~~~json', | |
| JSON.stringify(sourceOfTruth, null, 2), | |
| '~~~', | |
| '', | |
| '</details>', | |
| ) | |
| summaryLines.push('', marker, statePrefix + JSON.stringify(state, null, 2) + stateSuffix) | |
| writeFileSync(bodyPath, summaryLines.join('\n') + '\n') | |
| writeFileSync(commentIdPath, existing?.id ? String(existing.id) : '') | |
| EOF | |
| node "$renderer_script" "$comparison_file" "$comments_json" "$comment_body" "$comment_id_file" "$chart_file" "$chart_dark_file" | |
| rerender_ci_measurement_comment() { | |
| node "$renderer_script" "$comparison_file" "$comments_json" "$comment_body" "$comment_id_file" "$chart_file" "$chart_dark_file" | |
| } | |
| drop_ci_measurement_chart_source() { | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_SOURCE_URL="" | |
| rerender_ci_measurement_comment | |
| } | |
| drop_ci_measurement_chart_images() { | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_URL="" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_DARK_URL="" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_SOURCE_URL="" | |
| rerender_ci_measurement_comment | |
| } | |
| if [ -s "$chart_file" ]; then | |
| if [ "$require_public_asset" = "true" ] && [ -z "$public_asset_command" ]; then | |
| echo "::error::CI measurement chart was rendered for a private repository, but CI_MEASUREMENT_PR_COMMENT_PUBLIC_ASSET_COMMAND is not configured. Private raw GitHub URLs cannot be embedded in PR comments." | |
| exit 1 | |
| fi | |
| if ensure_ci_measurement_tool resvg resvg; then | |
| resvg_font_args=() | |
| if command -v nix >/dev/null 2>&1; then | |
| if font_out="$(nix build --no-link --print-out-paths nixpkgs#dejavu_fonts 2>/dev/null)"; then | |
| resvg_font_args+=(--use-fonts-dir "$font_out/share/fonts/truetype") | |
| fi | |
| fi | |
| if ! resvg --background '#ffffff' "${resvg_font_args[@]}" "$chart_file" "$chart_png_file"; then | |
| echo "::notice::unable to render CI measurement chart PNG" | |
| rm -f "$chart_png_file" | |
| fi | |
| if [ -s "$chart_dark_file" ] && ! resvg --background '#0d1117' "${resvg_font_args[@]}" "$chart_dark_file" "$chart_dark_png_file"; then | |
| echo "::notice::unable to render dark CI measurement chart PNG" | |
| rm -f "$chart_dark_png_file" | |
| fi | |
| else | |
| echo "::notice::resvg is not available; skipping embedded CI measurement chart PNG" | |
| fi | |
| if ! gh api "repos/$repo/git/ref/heads/$asset_branch" >/dev/null 2>&1; then | |
| default_branch_sha="$(gh api "repos/$repo/git/ref/heads/${GITHUB_BASE_REF:-main}" --jq '.object.sha' 2>/dev/null || true)" | |
| if [ -z "$default_branch_sha" ]; then | |
| default_branch_sha="${GITHUB_SHA:-}" | |
| fi | |
| if [ -n "$default_branch_sha" ]; then | |
| gh api "repos/$repo/git/refs" --method POST --field ref="refs/heads/$asset_branch" --field sha="$default_branch_sha" >/dev/null || true | |
| fi | |
| fi | |
| chart_content="$(base64 <"$chart_file" | tr -d '\n')" | |
| if ! gh api "repos/$repo/contents/$asset_svg_path" --method PUT --field message="Update CI measurement chart SVG for PR #$pr_number" --field content="$chart_content" --field branch="$asset_branch" >/dev/null; then | |
| echo "::notice::unable to upload CI measurement chart SVG asset" | |
| if [ -z "$public_asset_command" ]; then | |
| drop_ci_measurement_chart_source | |
| fi | |
| fi | |
| if [ -s "$chart_png_file" ]; then | |
| chart_png_content="$(base64 <"$chart_png_file" | tr -d '\n')" | |
| if ! gh api "repos/$repo/contents/$asset_png_path" --method PUT --field message="Update CI measurement chart PNG for PR #$pr_number" --field content="$chart_png_content" --field branch="$asset_branch" >/dev/null; then | |
| echo "::notice::unable to upload CI measurement chart PNG asset" | |
| if [ -z "$public_asset_command" ]; then | |
| drop_ci_measurement_chart_images | |
| fi | |
| fi | |
| else | |
| drop_ci_measurement_chart_images | |
| fi | |
| if [ -s "$chart_dark_png_file" ]; then | |
| chart_dark_png_content="$(base64 <"$chart_dark_png_file" | tr -d '\n')" | |
| if ! gh api "repos/$repo/contents/$asset_dark_png_path" --method PUT --field message="Update dark CI measurement chart PNG for PR #$pr_number" --field content="$chart_dark_png_content" --field branch="$asset_branch" >/dev/null; then | |
| echo "::notice::unable to upload dark CI measurement chart PNG asset" | |
| if [ -z "$public_asset_command" ]; then | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_DARK_URL="" | |
| rerender_ci_measurement_comment | |
| fi | |
| fi | |
| fi | |
| if [ -n "$public_asset_command" ] && [ -s "$chart_png_file" ]; then | |
| if public_chart_url="$(bash -c "$public_asset_command" _ "$chart_png_file" png)" && [ -n "$public_chart_url" ]; then | |
| chart_url="$public_chart_url" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_URL="$chart_url" | |
| else | |
| echo "::notice::unable to publish CI measurement chart PNG to public asset host" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_URL="" | |
| fi | |
| if [ -s "$chart_dark_png_file" ] && public_chart_dark_url="$(bash -c "$public_asset_command" _ "$chart_dark_png_file" png)" && [ -n "$public_chart_dark_url" ]; then | |
| chart_dark_url="$public_chart_dark_url" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_DARK_URL="$chart_dark_url" | |
| else | |
| echo "::notice::unable to publish dark CI measurement chart PNG to public asset host" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_DARK_URL="" | |
| fi | |
| if public_chart_source_url="$(bash -c "$public_asset_command" _ "$chart_file" svg)" && [ -n "$public_chart_source_url" ]; then | |
| chart_source_url="$public_chart_source_url" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_SOURCE_URL="$chart_source_url" | |
| else | |
| echo "::notice::unable to publish CI measurement chart SVG to public asset host" | |
| export CI_MEASUREMENT_PR_COMMENT_CHART_SOURCE_URL="" | |
| fi | |
| if [ "$require_public_asset" = "true" ] && [ -z "$chart_url" ]; then | |
| echo "::error::unable to publish CI measurement chart PNG to a public asset host for private repository $repo" | |
| exit 1 | |
| fi | |
| if [ "$require_public_asset" = "true" ] && [ -s "$chart_dark_png_file" ] && [ -z "$chart_dark_url" ]; then | |
| echo "::error::unable to publish dark CI measurement chart PNG to a public asset host for private repository $repo" | |
| exit 1 | |
| fi | |
| node "$renderer_script" "$comparison_file" "$comments_json" "$comment_body" "$comment_id_file" "$chart_file" "$chart_dark_file" | |
| fi | |
| fi | |
| comment_id="$(cat "$comment_id_file")" | |
| comment_payload_file="$comment_body.payload.json" | |
| node -e "const fs=require('node:fs'); fs.writeFileSync(process.argv[2], JSON.stringify({ body: fs.readFileSync(process.argv[1], 'utf8') }))" "$comment_body" "$comment_payload_file" | |
| if [ -n "$comment_id" ]; then | |
| if ! gh api "repos/$repo/issues/comments/$comment_id" --method PATCH --input "$comment_payload_file" >/dev/null; then | |
| echo "::notice::unable to update CI measurement PR comment" | |
| fi | |
| else | |
| if ! gh api "repos/$repo/issues/$pr_number/comments" --method POST --input "$comment_payload_file" >/dev/null; then | |
| echo "::notice::unable to create CI measurement PR comment" | |
| fi | |
| fi | |
| fi | |
| fi | |
| fi | |
| if [ "$exit_code" -ne 0 ]; then | |
| exit "$exit_code" | |
| fi | |
| - name: 'Upload CI measurements: ci-measurements-report' | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ci-measurements-report | |
| path: | | |
| tmp/ci-measurement-report | |
| !tmp/ci-measurement-report/baseline/** | |
| if-no-files-found: error | |
| retention-days: 14 | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-ci-measurements-report" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| test-integration-notion: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 90 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }} | |
| NOTION_TEST_PARENT_PAGE_ID: ${{ secrets.NOTION_TEST_PARENT_PAGE_ID }} | |
| NOTION_DATASOURCE_SYNC_PARENT_PAGE_ID: ${{ secrets.NOTION_DATASOURCE_SYNC_PARENT_PAGE_ID || secrets.NOTION_TEST_PARENT_PAGE_ID }} | |
| NOTION_DATASOURCE_SYNC_E2E_LEDGER_PAGE_ID: ${{ secrets.NOTION_DATASOURCE_SYNC_E2E_LEDGER_PAGE_ID }} | |
| NOTION_DATASOURCE_SYNC_DEMO_PAGE_ID: ${{ github.event_name == 'workflow_dispatch' && (inputs.run_datasource_sync_demo == true || inputs.run_datasource_sync_demo == 'true') && secrets.NOTION_DATASOURCE_SYNC_DEMO_PAGE_ID || '' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Notion integration tests | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run test:notion-integration --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run test:notion-integration --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-test-integration-notion" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| test-integration-restate: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| concurrency: | |
| group: 'test-integration-restate-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}' | |
| cancel-in-progress: true | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 60 | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - name: Restate integration tests | |
| run: | | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run test:restate-integration --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run test:restate-integration --mode before' | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| deploy-storybooks: | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') }} | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| FORCE_SETUP: '1' | |
| CI: 'true' | |
| GITHUB_TOKEN: ${{ github.token }} | |
| NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Checkout CI measurement baseline ref | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' }} | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ inputs.measurement_baseline_ref }} | |
| - name: Install Nix | |
| uses: DeterminateSystems/determinate-nix-action@v3 | |
| with: | |
| extra-conf: | | |
| experimental-features = nix-command flakes | |
| accept-flake-config = true | |
| extra-substituters = https://devenv.cachix.org | |
| extra-trusted-public-keys = devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= | |
| access-tokens = github.com=${{ github.token }} | |
| summarize: true | |
| - name: Provide cachix CLI from nixpkgs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| out=$(nix build --no-link --print-out-paths nixpkgs#cachix) | |
| echo "$out/bin" >> "$GITHUB_PATH" | |
| - name: Enable Cachix cache | |
| uses: cachix/cachix-action@v17 | |
| with: | |
| name: overeng-effect-utils | |
| authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} | |
| - name: Use pinned devenv from lock | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| echo "DEVENV_REV=$DEVENV_REV" >> "$GITHUB_ENV" | |
| echo "Pinned devenv rev: $DEVENV_REV" | |
| shell: bash | |
| - name: Isolate pnpm state | |
| shell: bash | |
| run: | | |
| echo "PNPM_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_CONFIG_STORE_DIR=${{ runner.temp }}/pnpm-store/${{ github.job }}" >> "$GITHUB_ENV" | |
| echo "PNPM_HOME=${{ github.workspace }}/.pnpm-home" >> "$GITHUB_ENV" | |
| - id: restore-pnpm-state | |
| name: Restore pnpm state | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Resolve devenv | |
| run: | | |
| DEVENV_REV=$(jq -r .nodes.devenv.locked.rev devenv.lock) | |
| if [ -z "$DEVENV_REV" ] || [ "$DEVENV_REV" = "null" ]; then | |
| echo '::error::devenv.lock missing .nodes.devenv.locked.rev' | |
| exit 1 | |
| fi | |
| resolve_devenv() { | |
| nix build \ | |
| --accept-flake-config \ | |
| --option extra-substituters https://devenv.cachix.org \ | |
| --option extra-trusted-public-keys devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw= \ | |
| --no-link \ | |
| --print-out-paths \ | |
| "github:cachix/devenv/$DEVENV_REV#devenv" | |
| } | |
| # Temporary: capture diagnostics dir for #272 root-cause analysis. | |
| DIAG_ROOT="${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-${GITHUB_JOB:-job}-${RUNNER_OS:-unknown}-${GITHUB_RUN_ATTEMPT:-0}" | |
| mkdir -p "$DIAG_ROOT" | |
| echo "NIX_STORE_DIAGNOSTICS_DIR=$DIAG_ROOT" >> "$GITHUB_ENV" | |
| { | |
| echo "timestamp_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "runner_name=${RUNNER_NAME:-unknown}" | |
| echo "runner_os=${RUNNER_OS:-unknown}" | |
| echo "runner_arch=${RUNNER_ARCH:-unknown}" | |
| echo "github_job=${GITHUB_JOB:-unknown}" | |
| echo "github_run_id=${GITHUB_RUN_ID:-unknown}" | |
| echo "nix_user_conf_files=${NIX_USER_CONF_FILES:-}" | |
| nix --version || true | |
| } > "$DIAG_ROOT/environment.txt" 2>&1 | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv.log" >&2)); then | |
| echo "::error::resolve_devenv failed. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| # Fast validity check on the devenv store path (~1-2s vs ~25s for devenv info). | |
| if ! nix-store --check-validity "$DEVENV_OUT" 2>/dev/null; then | |
| echo "::warning::devenv store path invalid, repairing targeted path..." | |
| nix-store --repair-path "$DEVENV_OUT" > "$DIAG_ROOT/nix-store-verify-repair.log" 2>&1 || true | |
| rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}"/nix/eval-cache-* ~/.cache/nix/eval-cache-* | |
| if ! DEVENV_OUT=$(resolve_devenv 2> >(tee "$DIAG_ROOT/resolve-devenv-post-repair.log" >&2)); then | |
| echo "::error::resolve_devenv failed after repair. Last 30 lines of log:" | |
| tail -30 "$DIAG_ROOT/resolve-devenv-post-repair.log" || true | |
| exit 1 | |
| fi | |
| DEVENV_BIN="$DEVENV_OUT/bin/devenv" | |
| fi | |
| echo "DEVENV_BIN=$DEVENV_BIN" >> "$GITHUB_ENV" | |
| "$DEVENV_BIN" version | tee "$DIAG_ROOT/devenv-version.txt" | |
| shell: bash | |
| - name: Evict cached pnpm deps for oxlint-npm | |
| shell: bash | |
| run: | | |
| targetRef='.#oxlint-npm' | |
| entriesJson=$(mktemp) | |
| if nix eval --json "$targetRef.passthru.depsBuildEntries" >"$entriesJson" 2>/dev/null; then | |
| while IFS=$'\t' read -r attrName drv; do | |
| [ -n "$drv" ] || continue | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(jq -r '.[] | [.attrName, (.drvPath // "")] | @tsv' "$entriesJson") | |
| else | |
| topDrv=$(nix path-info --derivation "$targetRef" 2>/dev/null || true) | |
| if [ -n "$topDrv" ]; then | |
| while IFS= read -r drv; do | |
| [ -n "$drv" ] || continue | |
| attrName="" | |
| while IFS= read -r outPath; do | |
| [ -n "$outPath" ] || continue | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "evicting cached: $(basename "$outPath")" | |
| if ! nix store delete --ignore-liveness "$outPath" >/dev/null 2>&1; then | |
| echo "::error::failed to evict cached pnpm-deps output: $outPath" | |
| exit 1 | |
| fi | |
| if nix path-info "$outPath" >/dev/null 2>&1; then | |
| echo "::error::cached pnpm-deps output still present after eviction: $outPath" | |
| exit 1 | |
| fi | |
| fi | |
| done < <(nix-store -q --outputs "$drv" 2>/dev/null || true) | |
| done < <(nix-store -qR "$topDrv" 2>/dev/null | grep "pnpm-deps-[a-z0-9-]*-v[0-9].*\.drv$" || true) | |
| fi | |
| fi | |
| rm -f "$entriesJson" | |
| - name: Force diagnostics failure (debug) | |
| if: ${{ github.event_name == 'workflow_dispatch' && (inputs.debug_force_nix_diagnostics_failure == true || inputs.debug_force_nix_diagnostics_failure == 'true') }} | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-${RUNNER_TEMP:-/tmp}/nix-store-diagnostics-missing}" | |
| mkdir -p "$diag_dir" | |
| cat > "$diag_dir/synthetic-signature.log" <<'EOF' | |
| Failed to convert config.cachix to JSON | |
| ... while evaluating the option `cachix.package` | |
| error: path '/nix/store/synthetic-invalid-path' is not valid | |
| EOF | |
| echo "::warning::Intentional failure for diagnostics validation (#272)" | |
| exit 1 | |
| - id: deploy | |
| name: Deploy storybooks to Netlify | |
| shell: bash | |
| run: | | |
| tmp_log="/tmp/netlify-storybook-deploy.log" | |
| rm -f "$tmp_log" | |
| workflow_report_dir="${RUNNER_TEMP:-/tmp}/workflow-reports" | |
| mkdir -p "$workflow_report_dir" | |
| workflow_report_path="$(mktemp "$workflow_report_dir/netlify-storybooks.XXXXXX.jsonl")" | |
| export WORKFLOW_REPORT_OUTPUT_FILE="$workflow_report_path" | |
| if [ -z "${NETLIFY_AUTH_TOKEN:-}" ]; then | |
| deployed_at_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| workflow_report_json="$(jq -cn \ | |
| --argjson schemaVersion 1 \ | |
| --arg tag WorkflowReportRecord \ | |
| --arg id deploy-netlify-storybooks \ | |
| --arg kind "deploy-preview" \ | |
| --arg status skipped \ | |
| --arg provider netlify \ | |
| --arg target storybooks \ | |
| --arg displayName "Storybooks" \ | |
| --arg deployedAtUtc "$deployed_at_utc" \ | |
| --arg message "Skipping Netlify deploy because NETLIFY_AUTH_TOKEN is not available." \ | |
| '{_tag: $tag, schemaVersion: $schemaVersion, id: $id, kind: $kind, subject: {id: $target, label: $displayName}, status: $status, title: ($displayName + " preview not deployed"), summary: $message, createdAtUtc: $deployedAtUtc, data: {provider: $provider, target: $target, displayName: $displayName, reason: $message}}')" | |
| printf "%s\n" "$workflow_report_json" > "$workflow_report_path" | |
| echo "WORKFLOW_REPORT_V1: $workflow_report_json" | |
| echo "workflow_report_path=$workflow_report_path" >> "$GITHUB_OUTPUT" | |
| printf 'workflow_report=%s\n' "$workflow_report_json" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Skipping Netlify deploy (NETLIFY_AUTH_TOKEN not available)" | |
| exit 0 | |
| fi | |
| if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run netlify:deploy --show-output --input type=prod --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run netlify:deploy --show-output --input type=prod --mode before' 2>&1 | tee "$tmp_log" | |
| elif [ "${{ github.event_name }}" = "pull_request" ]; then | |
| __nix_gc_retry_helper=$(mktemp) | |
| cat > "$__nix_gc_retry_helper" <<'EOF' | |
| #!/usr/bin/env bash | |
| run_nix_gc_race_retry() { | |
| local task="$1" | |
| local command="$2" | |
| local max="${NIX_GC_RACE_MAX_RETRIES:-10}" | |
| local heartbeat="${CI_PROGRESS_HEARTBEAT_SECONDS:-60}" | |
| local attempt=1 | |
| local log rc path start now elapsed hb_pid flattened saw_invalid_path saw_cachix_signature saw_fetch_signature saw_daemon_socket_failure had_errexit | |
| start="$(date +%s)" | |
| write_summary() { | |
| [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 | |
| { | |
| echo "### CI Task" | |
| echo "- Task: $task" | |
| echo "- Status: $1" | |
| echo "- Duration: $elapsed s" | |
| echo "- Attempts: $attempt/$max" | |
| [ -z "${2:-}" ] || echo "- Note: $2" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| repair_nix_daemon() { | |
| echo "::warning::Nix daemon socket is unavailable; attempting daemon restart before retry" | |
| if command -v launchctl >/dev/null 2>&1; then | |
| sudo launchctl kickstart -k system/org.nixos.nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if command -v systemctl >/dev/null 2>&1; then | |
| sudo systemctl restart nix-daemon.socket >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon.service >/dev/null 2>&1 || true | |
| sudo systemctl restart nix-daemon >/dev/null 2>&1 || true | |
| fi | |
| if [ ! -S /nix/var/nix/daemon-socket/socket ] && [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then | |
| sudo /nix/var/nix/profiles/default/bin/nix-daemon --daemon >/tmp/nix-daemon-restart.log 2>&1 || true | |
| fi | |
| for _ in 1 2 3 4 5; do | |
| [ -S /nix/var/nix/daemon-socket/socket ] && return 0 | |
| sleep 1 | |
| done | |
| return 0 | |
| } | |
| while [ "$attempt" -le "$max" ]; do | |
| echo "::notice::[ci] starting $task (attempt $attempt/$max)" | |
| ( | |
| while sleep "$heartbeat"; do | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::notice::[ci] $task still running after $elapsed s (attempt $attempt/$max)" | |
| done | |
| ) & | |
| hb_pid=$! | |
| log=$(mktemp) | |
| had_errexit=false | |
| case $- in | |
| *e*) had_errexit=true ;; | |
| esac | |
| set +e | |
| eval "$command" > >(tee -a "$log") 2> >(tee -a "$log" >&2) | |
| rc=$? | |
| if [ "$had_errexit" = true ]; then | |
| set -e | |
| fi | |
| kill "$hb_pid" 2>/dev/null || true | |
| wait "$hb_pid" 2>/dev/null || true | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| if [ "$rc" -eq 0 ]; then | |
| echo "::notice::[ci] completed $task in $elapsed s" | |
| if [ "$attempt" -gt 1 ]; then | |
| write_summary success "Recovered from transient Nix failure after retry" | |
| else | |
| write_summary success | |
| fi | |
| rm -f "$log" | |
| return 0 | |
| fi | |
| flattened=$(tr '\r\n' ' ' < "$log" | sed -E $'s/\x1B\[[0-9;]*m//g') | |
| path=$(printf '%s' "$flattened" | | |
| grep -o "error:[[:space:]]*path '/nix/store/[^']*'[[:space:]]*is not valid" | | |
| head -1 | | |
| grep -o "/nix/store/[^']*" | | |
| tr -d '[:space:]' || true) | |
| saw_invalid_path=false | |
| saw_cachix_signature=false | |
| saw_fetch_signature=false | |
| saw_daemon_socket_failure=false | |
| [ -n "$path" ] && saw_invalid_path=true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*Failed to convert config\.cachix to JSON' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*.*while evaluating the option.*cachix\.package' && saw_cachix_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq 'error:[[:space:]]*cannot read file from tarball:[[:space:]]*Truncated tar archive detected while reading data' && saw_fetch_signature=true || true | |
| printf '%s' "$flattened" | grep -Eq "error:[[:space:]]*cannot connect to socket at '/nix/var/nix/daemon-socket/socket'" && saw_daemon_socket_failure=true || true | |
| rm -f "$log" | |
| if [ "$saw_invalid_path" != true ] && [ "$saw_cachix_signature" != true ] && [ "$saw_fetch_signature" != true ] && [ "$saw_daemon_socket_failure" != true ]; then | |
| echo "::warning::[ci] $task failed after $elapsed s without a detected transient Nix failure" | |
| write_summary failure "No transient Nix failure signature detected" | |
| return "$rc" | |
| fi | |
| if [ "$saw_daemon_socket_failure" = true ]; then | |
| repair_nix_daemon | |
| echo "::warning::Nix daemon socket failure detected for $task (attempt $attempt/$max); retrying after daemon repair" | |
| elif [ "$saw_fetch_signature" = true ]; then | |
| echo "::warning::Nix source fetch corruption detected for $task (attempt $attempt/$max); retrying with a refreshed eval cache" | |
| elif [ "$saw_cachix_signature" = true ] && [ -n "$path" ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper (attempt $attempt/$max): $path" | |
| elif [ "$saw_cachix_signature" = true ]; then | |
| echo "::warning::Nix store validity race detected for $task via cachix eval wrapper without extracted store path (attempt $attempt/$max)" | |
| else | |
| echo "::warning::Nix store validity race detected for $task (attempt $attempt/$max): $path" | |
| fi | |
| [ -z "$path" ] || nix-store --realise "$path" 2>/dev/null || true | |
| rm -rf ~/.cache/nix/eval-cache-* | |
| attempt=$((attempt + 1)) | |
| done | |
| now=$(date +%s) | |
| elapsed=$((now - start)) | |
| echo "::error::Transient Nix retry exhausted for $task ($max attempts)" | |
| write_summary failure "Transient Nix retry exhausted" | |
| return 1 | |
| } | |
| EOF | |
| . "$__nix_gc_retry_helper" | |
| rm -f "$__nix_gc_retry_helper" | |
| run_nix_gc_race_retry 'devenv tasks run netlify:deploy --show-output --input type=pr --input pr=${{ github.event.pull_request.number }} --mode before' 'if [ -n "${NIX_CONFIG:-}" ]; then NIX_CONFIG_WITH_APPEND=$(printf '"'"'%s\n%s'"'"' "$NIX_CONFIG" '"'"'restrict-eval = false'"'"'); else NIX_CONFIG_WITH_APPEND='"'"'restrict-eval = false'"'"'; fi; NIX_CONFIG="$NIX_CONFIG_WITH_APPEND" PNPM_HOME="${PNPM_HOME:-${{ github.workspace }}/.pnpm-home}" PNPM_STORE_DIR="${PNPM_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" PNPM_CONFIG_STORE_DIR="${PNPM_CONFIG_STORE_DIR:-${{ runner.temp }}/pnpm-store/${{ github.job }}}" DT_PASSTHROUGH=1 "${DEVENV_BIN:?DEVENV_BIN not set}" tasks run netlify:deploy --show-output --input type=pr --input pr=${{ github.event.pull_request.number }} --mode before' 2>&1 | tee "$tmp_log" | |
| fi | |
| deploy_exit=${PIPESTATUS[0]} | |
| if [ "$deploy_exit" -ne 0 ]; then exit "$deploy_exit"; fi | |
| workflow_report_json="" | |
| if [ -s "$workflow_report_path" ]; then | |
| workflow_report_json="$(tail -n 1 "$workflow_report_path")" | |
| fi | |
| if [ -z "$workflow_report_json" ]; then | |
| workflow_report_json=$(grep -F "WORKFLOW_REPORT_V1: " "$tmp_log" | sed 's/^.*WORKFLOW_REPORT_V1: //' | tail -n 1 || true) | |
| fi | |
| if [ -n "$workflow_report_json" ]; then | |
| workflow_report_json="$(printf "%s" "$workflow_report_json" | jq -c '.')" | |
| printf "%s\n" "$workflow_report_json" > "$workflow_report_path" | |
| echo "WORKFLOW_REPORT_V1: $workflow_report_json" | |
| printf 'workflow_report=%s\n' "$workflow_report_json" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "workflow_report_path=$workflow_report_path" >> "$GITHUB_OUTPUT" | |
| - name: Collect workflow report bundle | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| WORKFLOW_REPORT_FLAKE_REF: "github:${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}/${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}#workflow-report" | |
| WORKFLOW_REPORT_BUNDLE_ID: storybook-preview | |
| WORKFLOW_REPORT_INPUT_PATHS_JSON: '["${{ steps.deploy.outputs.workflow_report_path }}"]' | |
| WORKFLOW_REPORT_OUTPUT_PATH: '${{ runner.temp }}/workflow-reports/storybook-preview-bundle.json' | |
| WORKFLOW_REPORT_RECORD_MARKER: 'WORKFLOW_REPORT_V1: ' | |
| WORKFLOW_REPORT_ALLOW_MISSING_INPUT: '1' | |
| run: | | |
| if [ -n "${GH_TOKEN:-}" ]; then | |
| export NIX_CONFIG="${NIX_CONFIG:+$NIX_CONFIG$'\n'}access-tokens = github.com=${GH_TOKEN}" | |
| fi | |
| workflow_report_flake_ref="${WORKFLOW_REPORT_FLAKE_REF:-github:overengineeringstudio/effect-utils/main#workflow-report}" | |
| nix run "$workflow_report_flake_ref" -- collect-bundle --bundle-id "$WORKFLOW_REPORT_BUNDLE_ID" --input-paths-json "$WORKFLOW_REPORT_INPUT_PATHS_JSON" --output-path "$WORKFLOW_REPORT_OUTPUT_PATH" --record-marker "$WORKFLOW_REPORT_RECORD_MARKER" --allow-missing-input | |
| - name: Render workflow report comment | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| WORKFLOW_REPORT_FLAKE_REF: "github:${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}/${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}#workflow-report" | |
| WORKFLOW_REPORT_BUNDLE_PATH: '${{ runner.temp }}/workflow-reports/storybook-preview-bundle.json' | |
| WORKFLOW_REPORT_COMMENT_BODY_PATH: '${{ runner.temp }}/workflow-reports/storybook-preview-comment.md' | |
| WORKFLOW_REPORT_SUMMARY_PATH: '${{ runner.temp }}/workflow-reports/storybook-preview-summary.md' | |
| WORKFLOW_REPORT_TITLE: Storybook Previews | |
| WORKFLOW_REPORT_NO_RECORDS_MESSAGE: No storybooks were deployed. | |
| WORKFLOW_REPORT_STATE_ID: storybook-preview | |
| WORKFLOW_REPORT_ENTRY_ID: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| WORKFLOW_REPORT_ENTRY_LABEL: ${{ github.event_name == 'pull_request' && format('PR {0}', github.event.pull_request.number) || 'prod' }} | |
| WORKFLOW_REPORT_CREATED_AT_UTC: '' | |
| WORKFLOW_REPORT_TIME_ZONE: UTC | |
| WORKFLOW_REPORT_MANAGED_MARKER: '<!-- workflow-report:managed -->' | |
| run: | | |
| if [ -n "${GH_TOKEN:-}" ]; then | |
| export NIX_CONFIG="${NIX_CONFIG:+$NIX_CONFIG$'\n'}access-tokens = github.com=${GH_TOKEN}" | |
| fi | |
| comments_json="$(mktemp)" | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| nix run nixpkgs#gh -- api "repos/$GH_REPO/issues/${{ github.event.pull_request.number }}/comments" --paginate > "$comments_json" | |
| else | |
| printf '[]' > "$comments_json" | |
| fi | |
| workflow_report_flake_ref="${WORKFLOW_REPORT_FLAKE_REF:-github:overengineeringstudio/effect-utils/main#workflow-report}" | |
| nix run "$workflow_report_flake_ref" -- render-comment-body --bundle-path "$WORKFLOW_REPORT_BUNDLE_PATH" --comments-path "$comments_json" --comment-body-path "$WORKFLOW_REPORT_COMMENT_BODY_PATH" --summary-path "$WORKFLOW_REPORT_SUMMARY_PATH" --title "$WORKFLOW_REPORT_TITLE" --no-records-message "$WORKFLOW_REPORT_NO_RECORDS_MESSAGE" --state-id "$WORKFLOW_REPORT_STATE_ID" --entry-id "$WORKFLOW_REPORT_ENTRY_ID" --entry-label "$WORKFLOW_REPORT_ENTRY_LABEL" --created-at-utc "$WORKFLOW_REPORT_CREATED_AT_UTC" --time-zone "$WORKFLOW_REPORT_TIME_ZONE" --managed-marker "$WORKFLOW_REPORT_MANAGED_MARKER" | |
| - name: Publish workflow report | |
| if: always() && !cancelled() | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| WORKFLOW_REPORT_FLAKE_REF: "github:${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}/${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}#workflow-report" | |
| WORKFLOW_REPORT_STATE_ID: storybook-preview | |
| WORKFLOW_REPORT_COMMENT_BODY_PATH: '${{ runner.temp }}/workflow-reports/storybook-preview-comment.md' | |
| WORKFLOW_REPORT_SUMMARY_PATH: '${{ runner.temp }}/workflow-reports/storybook-preview-summary.md' | |
| WORKFLOW_REPORT_MANAGED_MARKER: '<!-- workflow-report:managed -->' | |
| run: | | |
| if [ -n "${GH_TOKEN:-}" ]; then | |
| export NIX_CONFIG="${NIX_CONFIG:+$NIX_CONFIG$'\n'}access-tokens = github.com=${GH_TOKEN}" | |
| fi | |
| if [ -s "$WORKFLOW_REPORT_SUMMARY_PATH" ]; then | |
| cat "$WORKFLOW_REPORT_SUMMARY_PATH" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| exit 0 | |
| fi | |
| if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then | |
| echo "::notice::workflow report PR comment skipped for fork pull request; summary remains available in the job summary" | |
| exit 0 | |
| fi | |
| if [ ! -s "$WORKFLOW_REPORT_COMMENT_BODY_PATH" ]; then | |
| echo "::notice::workflow report comment body is empty; skipping PR comment" | |
| exit 0 | |
| fi | |
| comments_json="$(mktemp)" | |
| comment_id_file="$(mktemp)" | |
| nix run nixpkgs#gh -- api "repos/$GH_REPO/issues/${{ github.event.pull_request.number }}/comments" --paginate > "$comments_json" | |
| workflow_report_flake_ref="${WORKFLOW_REPORT_FLAKE_REF:-github:overengineeringstudio/effect-utils/main#workflow-report}" | |
| nix run "$workflow_report_flake_ref" -- find-comment --comments-path "$comments_json" --comment-body-path "$WORKFLOW_REPORT_COMMENT_BODY_PATH" --comment-id-path "$comment_id_file" --state-id "$WORKFLOW_REPORT_STATE_ID" --managed-marker "$WORKFLOW_REPORT_MANAGED_MARKER" | |
| comment_id="$(cat "$comment_id_file")" | |
| if [ -n "$comment_id" ]; then | |
| nix run nixpkgs#gh -- api \ | |
| --method PATCH \ | |
| "repos/$GH_REPO/issues/comments/$comment_id" \ | |
| --field body=@"$WORKFLOW_REPORT_COMMENT_BODY_PATH" >/dev/null | |
| else | |
| nix run nixpkgs#gh -- pr comment "${{ github.event.pull_request.number }}" --body-file "$WORKFLOW_REPORT_COMMENT_BODY_PATH" | |
| fi | |
| - name: Save pnpm state | |
| if: ${{ success() && steps.restore-pnpm-state.outputs.cache-hit != 'true' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ${{ github.workspace }}/.pnpm-home | |
| ${{ runner.temp }}/pnpm-store/${{ github.job }} | |
| key: "pnpm-state-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}" | |
| - name: Nix diagnostics summary | |
| if: failure() | |
| shell: bash | |
| run: | | |
| diag_dir="${NIX_STORE_DIAGNOSTICS_DIR:-}" | |
| if [ -z "$diag_dir" ] || [ ! -d "$diag_dir" ]; then | |
| echo "## Nix Store Diagnostics" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "No diagnostics directory found (validation may have failed before capture)." >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| { | |
| echo "## Nix Store Diagnostics" | |
| echo "" | |
| echo "Temporary instrumentation for #272; remove after root cause is confirmed and CI is stable." | |
| echo "" | |
| echo "- Diagnostics directory: \`$diag_dir\`" | |
| echo "- Tracking issue: https://github.com/overengineeringstudio/effect-utils/issues/272" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| markers_file="${RUNNER_TEMP:-/tmp}/nix-store-signature-markers.txt" | |
| grep -R -n -E "config\\.cachix|cachix\\.package|error: path '/nix/store/.+ is not valid" --exclude="$(basename "$markers_file")" "$diag_dir" > "$markers_file" || true | |
| if [ -s "$markers_file" ]; then | |
| { | |
| echo "" | |
| echo "### Signature markers" | |
| echo '```text' | |
| head -n 120 "$markers_file" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- No signature markers found in captured diagnostics." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Upload Nix diagnostics artifact | |
| if: failure() && env.NIX_STORE_DIAGNOSTICS_DIR != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: 'nix-store-diagnostics-${{ github.job }}-${{ runner.os }}-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}' | |
| path: ${{ env.NIX_STORE_DIAGNOSTICS_DIR }} | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: Failure note | |
| if: failure() | |
| shell: bash | |
| run: | | |
| echo "If this looks like Namespace runner Nix store corruption (e.g. \"... is not valid\", \"config.cachix\", \"cachix.package\"), add the run link + full nix-store output to:" | |
| echo " https://github.com/overengineeringstudio/effect-utils/issues/201" | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-deploy-storybooks" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} | |
| notify-alignment: | |
| runs-on: | |
| [namespace-profile-linux-x86-64, 'namespace-features:github.run-id=${{ github.run_id }}'] | |
| timeout-minutes: 30 | |
| needs: | |
| - typecheck | |
| - lint | |
| - test | |
| - nix-check | |
| - nix-fod-check | |
| - pnpm-builder-contract | |
| - pnpm-regression | |
| - bundle-smoke | |
| - cargo | |
| - deploy-storybooks | |
| if: ${{ (github.ref == 'refs/heads/main') && github.event_name == 'push' }} | |
| steps: | |
| - name: Dispatch alignment to coordinator | |
| env: | |
| GH_TOKEN: ${{ secrets.MEGAREPO_ALIGNMENT_TOKEN }} | |
| run: | | |
| payload=$(printf '{"event_type":"upstream-changed","client_payload":{"source_repo":"%s","source_sha":"%s"}}' "${{ github.repository }}" "${{ github.sha }}") | |
| curl --fail-with-body --silent --show-error --request POST \ | |
| --url "https://api.github.com/repos/schickling/megarepo-all/dispatches" \ | |
| --header "Accept: application/vnd.github+json" \ | |
| --header "Content-Type: application/json" \ | |
| --header "Authorization: Bearer ${GH_TOKEN}" \ | |
| --header "X-GitHub-Api-Version: 2022-11-28" \ | |
| --data "$payload" | |
| shell: bash | |
| concurrency: | |
| group: "${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '' && format('measurement-baseline-{0}', inputs.measurement_baseline_ref) || (github.event_name == 'workflow_dispatch' && format('manual-run-{0}', github.run_id) || (github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && format('label-{0}', github.event.label.name) || 'code')) }}-notify-alignment" | |
| cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.measurement_baseline_ref != '') && (github.event_name != 'pull_request' || (github.event.action != 'labeled' && github.event.action != 'unlabeled')) }} |