diff --git a/.github/workflows/free-durations-record.yml b/.github/workflows/free-durations-record.yml index 0da6ab580..848be5dfc 100644 --- a/.github/workflows/free-durations-record.yml +++ b/.github/workflows/free-durations-record.yml @@ -101,7 +101,7 @@ jobs: - name: Open or refresh the seed PR when either seed's file set changed env: GH_TOKEN: ${{ github.token }} - OPEN_PR: ${{ github.event_name == 'schedule' || inputs.open_pr }} + OPEN_PR: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || inputs.open_pr) }} run: | set -euo pipefail linux_failed=$(wc -l < "$RUNNER_TEMP/linux/record-failures.txt") @@ -127,22 +127,32 @@ jobs: if ratchet=$(GSTACK_FREE_SEED_BASE="$(git rev-parse HEAD)" bun test test/free-seed-ratchet.test.ts 2>&1); then ratchet_line="passes" else - ratchet_line="FAILS; a maintainer must split the named file or add an annotated allowlist entry on this branch before merge:"$'\n\n'"\`\`\`"$'\n'"$(printf '%s\n' "$ratchet" | grep -E 'Free seed ratchet|^- ' | head -20)"$'\n'"\`\`\`" + ratchet_line="FAILS; a maintainer must split the named file or add an annotated allowlist entry on this branch before merge:"$'\n\n'"\`\`\`"$'\n'"$(printf '%s\n' "$ratchet" | sed -n -E '/Free seed ratchet|^- /p' | sed -n '1,20p')"$'\n'"\`\`\`" fi failed_line="Linux: $(failures "$RUNNER_TEMP/linux/record-failures.txt"); Windows: $(failures "$RUNNER_TEMP/windows/record-failures.txt")" echo "Seed growth ratchet: $ratchet_line" >> "$GITHUB_STEP_SUMMARY" echo "Failed while recording: $failed_line" | tee -a "$GITHUB_STEP_SUMMARY" if [ "$OPEN_PR" != true ]; then - echo "open_pr=false: recorded and reported only." | tee -a "$GITHUB_STEP_SUMMARY" + echo "Report only (open_pr=false, or a ref other than main): no branch pushed." | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi branch=bot/free-duration-seeds + subject="test(seed): refresh the free-suite duration seeds from the weekly recording" + number=$(gh pr list --head "$branch" --state open --json number -q '.[0].number // empty') + remote_head= + if git fetch -q origin "+refs/heads/$branch:refs/remotes/origin/$branch" 2>/dev/null; then + remote_head=$(git rev-parse "origin/$branch") + fi + if [ -n "$number" ] && [ -n "$remote_head" ] && [ "$(git log --format=%s "HEAD..$remote_head")" != "$subject" ]; then + echo "::warning::Seed PR #$number has commits besides the bot's one refresh commit; this run leaves them in place. Merge or close that PR and the next run refreshes the seeds." | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi git switch -C "$branch" git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com git add scripts/free-test-durations.json scripts/free-test-durations-windows.json - git commit -m "test(seed): refresh the free-suite duration seeds from the weekly recording" - git push --force origin "$branch" + git commit -m "$subject" + git push --force-with-lease="refs/heads/$branch:$remote_head" origin "HEAD:refs/heads/$branch" cat > pr-body.md < { + const script: string = proposeStep.run; + const line = script.split('\n').find(entry => entry.trimStart().startsWith('ratchet_line="FAILS'))!.trim(); + const ratchet = ['error: Free seed ratchet:', ...Array.from({ length: 3000 }, (_, i) => `- test/f${i}.test.ts is seeded at 61s, over the 60s ceiling. Fix: split it, then rerun the recording.`)].join('\n'); + const dir = mkdtempSync(path.join(os.tmpdir(), 'seed-ratchet-')); + dirs.push(dir); + writeFileSync(path.join(dir, 'ratchet.txt'), ratchet); + const result = spawnSync('bash', ['-c', `set -euo pipefail\nratchet=$(cat "$1")\n${line}\nprintf '%s' "$ratchet_line"`, 'ratchet', path.join(dir, 'ratchet.txt')], { encoding: 'utf8', timeout: 10_000 }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('- test/f18.test.ts is seeded at 61s, over'); + expect(result.stdout).not.toContain('- test/f19.test.ts'); +}); + +test('a refresh keeps a maintainer\'s commits on the open seed PR and leases the bot branch it replaces', () => { + const script: string = proposeStep.run; + const publish = script.slice(script.indexOf('branch=bot/free-duration-seeds'), script.indexOf('cat > pr-body.md')); + const dir = mkdtempSync(path.join(os.tmpdir(), 'seed-publish-')); + dirs.push(dir); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))); + const git = (cwd: string, ...args: string[]) => { + const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + const origin = path.join(dir, 'origin.git'); + const work = path.join(dir, 'work'); + git(dir, 'init', '-q', '--bare', '-b', 'main', origin); + git(dir, 'clone', '-q', origin, work); + const commit = (email: string, file: string, message: string) => { + git(work, 'config', 'user.name', email); + git(work, 'config', 'user.email', email); + writeFileSync(path.join(work, file), message); + git(work, 'add', file); + git(work, 'commit', '-q', '-m', message); + }; + for (const file of ['free-test-durations.json', 'free-test-durations-windows.json']) { + writeFileSync(path.join(work, file), '{}'); + } + git(work, 'checkout', '-q', '-b', 'main'); + commit('dev@example.com', 'README', 'main'); + git(work, 'push', '-q', 'origin', 'main'); + const subject = 'test(seed): refresh the free-suite duration seeds from the weekly recording'; + git(work, 'checkout', '-q', '-b', 'bot/free-duration-seeds'); + commit('bot@example.com', 'seed.txt', subject); + git(work, 'push', '-q', 'origin', 'bot/free-duration-seeds'); + const stub = path.join(dir, 'bin'); + spawnSync('mkdir', [stub], { timeout: 10_000 }); + writeFileSync(path.join(stub, 'gh'), '#!/bin/sh\necho 7\n', { mode: 0o755 }); + const run = () => { + git(work, 'checkout', '-q', 'main'); + spawnSync('mkdir', ['-p', path.join(work, 'scripts')], { timeout: 10_000 }); + writeFileSync(path.join(work, 'scripts/free-test-durations.json'), `{"run":${Date.now()}}`); + writeFileSync(path.join(work, 'scripts/free-test-durations-windows.json'), '{}'); + const result = spawnSync('bash', ['-c', `set -euo pipefail\n${publish}`], { cwd: work, encoding: 'utf8', timeout: 20_000, + env: { ...env, PATH: `${stub}:${process.env.PATH}`, GITHUB_STEP_SUMMARY: path.join(dir, 'summary.md') } }); + expect(result.status, result.stderr).toBe(0); + return git(origin, 'rev-parse', 'refs/heads/bot/free-duration-seeds'); + }; + const replaced = run(); + expect(git(origin, 'log', '--format=%s', `main..${replaced}`)).toBe(subject); + expect(git(origin, 'show', `${replaced}:scripts/free-test-durations.json`)).toContain('"run"'); + git(work, 'checkout', '-q', '-B', 'bot/free-duration-seeds', replaced); + commit('maintainer@example.com', 'allowlist.json', 'maintainer repair'); + git(work, 'push', '-q', 'origin', 'HEAD:refs/heads/bot/free-duration-seeds'); + const repaired = git(origin, 'rev-parse', 'refs/heads/bot/free-duration-seeds'); + expect(run()).toBe(repaired); + expect(readFileSync(path.join(dir, 'summary.md'), 'utf8')).toContain("Seed PR #7 has commits besides the bot's one refresh commit"); +}); + test('the drift check counts added and removed files, so an unchanged file set proposes nothing', () => { const script: string = proposeStep.run; const drift = script.slice(script.indexOf('drift() {'), script.indexOf('\n}', script.indexOf('drift() {')) + 2);