diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index dc3961ec9..7695e96f1 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -243,39 +243,3 @@ Mearman pr dodiego pr any-victor pr - -geraschenko pr - -skhoroshavin pr - -cyzlmh pr - -xz-dev pr - -rajp152k pr - -affanali2k3 pr - -ArcadiaLin pr - -anilgulecha pr - -DeviosLang pr - -HarrodRen pr - -aaronkyriesenbach pr - -farid-fari pr - -petrroll pr - -vibeinging pr - -DivineDominion pr - -ananthakumaran pr - -andrebreijao pr - -anh-chu pr diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index e77ab650c..355ad1cdb 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -17,17 +17,11 @@ on: permissions: {} -concurrency: - group: build-binaries-${{ github.event.inputs.tag || github.ref_name }} - cancel-in-progress: false - jobs: - # Keep the public GitHub Release publication last. Binary assets are staged in - # a draft release first; cleanup removes the draft if later publishing fails. build: runs-on: ubuntu-latest permissions: - contents: read + contents: write env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} @@ -41,7 +35,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.3.10 - name: Setup Node.js uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -52,105 +46,18 @@ jobs: - name: Build binaries run: ./scripts/build-binaries.sh - - name: Prepare GitHub release payload + - name: Extract changelog for this version + id: changelog run: | - set -euo pipefail - - mkdir -p release-assets - VERSION="${RELEASE_TAG}" VERSION="${VERSION#v}" # Remove 'v' prefix - node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out release-assets/RELEASE_NOTES.md - node scripts/generate-coding-agent-install-lock.mjs --check - cp packages/coding-agent/install-lock/package.json release-assets/pi-coding-agent-install-package.json - cp packages/coding-agent/install-lock/package-lock.json release-assets/pi-coding-agent-install-package-lock.json + node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md - cd packages/coding-agent/binaries - - binary_assets=( - pi-darwin-arm64.tar.gz - pi-darwin-x64.tar.gz - pi-linux-x64.tar.gz - pi-linux-arm64.tar.gz - pi-windows-x64.zip - pi-windows-arm64.zip - ) - - for asset in "${binary_assets[@]}"; do - test -f "${asset}" - done - - cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/" - - cd "${GITHUB_WORKSPACE}/release-assets" - release_assets=( - pi-darwin-arm64.tar.gz - pi-darwin-x64.tar.gz - pi-linux-x64.tar.gz - pi-linux-arm64.tar.gz - pi-windows-x64.zip - pi-windows-arm64.zip - pi-coding-agent-install-package.json - pi-coding-agent-install-package-lock.json - ) - sha256sum "${release_assets[@]}" > SHA256SUMS - - - name: Upload GitHub release payload - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: release-assets-${{ env.RELEASE_TAG }} - path: release-assets/* - if-no-files-found: error - retention-days: 14 - - stage-github-release: - runs-on: ubuntu-latest - needs: build - permissions: - actions: read - contents: write - env: - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} - steps: - - name: Download GitHub release payload - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: release-assets-${{ env.RELEASE_TAG }} - path: release-assets - - - name: Validate GitHub release payload - run: | - set -euo pipefail - - cd release-assets - - expected_assets=( - pi-darwin-arm64.tar.gz - pi-darwin-x64.tar.gz - pi-linux-x64.tar.gz - pi-linux-arm64.tar.gz - pi-windows-x64.zip - pi-windows-arm64.zip - pi-coding-agent-install-package.json - pi-coding-agent-install-package-lock.json - SHA256SUMS - RELEASE_NOTES.md - ) - - for asset in "${expected_assets[@]}"; do - test -f "${asset}" - done - - sha256sum -c SHA256SUMS - - - name: Create draft GitHub Release and upload binaries + - name: Create GitHub Release and upload binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - - cd release-assets + cd packages/coding-agent/binaries release_assets=( pi-darwin-arm64.tar.gz @@ -159,39 +66,25 @@ jobs: pi-linux-arm64.tar.gz pi-windows-x64.zip pi-windows-arm64.zip - pi-coding-agent-install-package.json - pi-coding-agent-install-package-lock.json - SHA256SUMS ) + sha256sum "${release_assets[@]}" > SHA256SUMS + release_assets+=(SHA256SUMS) - existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)" - if [[ "${existing_release}" == "false" ]]; then - echo "::error::GitHub Release ${RELEASE_TAG} is already published. Refusing to mutate a public release." - exit 1 - fi - if [[ "${existing_release}" == "true" ]]; then - gh release delete "${RELEASE_TAG}" --yes - fi - - gh release create "${RELEASE_TAG}" \ - --verify-tag \ - --draft \ - --title "${RELEASE_TAG}" \ - --notes-file RELEASE_NOTES.md \ - "${release_assets[@]}" - - expected_asset_names="$(printf '%s\n' "${release_assets[@]}" | sort)" - actual_asset_names="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)" - - if [[ "${actual_asset_names}" != "${expected_asset_names}" ]]; then - echo "::error::Draft GitHub Release asset set does not match expected files." - diff -u <(printf '%s\n' "${expected_asset_names}") <(printf '%s\n' "${actual_asset_names}") || true - exit 1 + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + gh release edit "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md + gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber + else + gh release create "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md \ + "${release_assets[@]}" fi publish-npm: runs-on: ubuntu-latest - needs: stage-github-release + needs: build environment: npm-publish permissions: contents: read @@ -231,6 +124,9 @@ jobs: - name: Test run: npm test + - name: Verify release artifacts are committed + run: git diff --exit-code + - name: Upgrade npm for trusted publishing run: | npm install -g npm@11.16.0 --ignore-scripts @@ -238,57 +134,3 @@ jobs: - name: Publish npm packages run: node scripts/publish.mjs - - publish-github-release: - runs-on: ubuntu-latest - needs: - - stage-github-release - - publish-npm - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} - steps: - - name: Publish staged GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)" - if [[ "${existing_release}" == "" ]]; then - echo "::error::Draft GitHub Release ${RELEASE_TAG} does not exist." - exit 1 - fi - if [[ "${existing_release}" == "false" ]]; then - echo "::error::GitHub Release ${RELEASE_TAG} is already published." - exit 1 - fi - - gh release edit "${RELEASE_TAG}" --draft=false - - cleanup-draft-github-release: - runs-on: ubuntu-latest - needs: - - build - - stage-github-release - - publish-npm - - publish-github-release - if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.publish-github-release.result != 'success') }} - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} - steps: - - name: Delete draft GitHub Release after failure - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)" - if [[ "${existing_release}" == "true" ]]; then - gh release delete "${RELEASE_TAG}" --yes - fi diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml deleted file mode 100644 index c4d0a1fb5..000000000 --- a/.github/workflows/issue-analysis.yml +++ /dev/null @@ -1,634 +0,0 @@ -# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added -# or when a staff member comments `@issuron analyze` anywhere on an issue. -# -# Comment triggers can include one `#run-on-*` tag anywhere in the text: -# @issuron analyze #run-on-linux -> ubuntu-latest (default) -# @issuron analyze #run-on-windows -> windows-latest -# @issuron analyze #run-on-mac -> macos-latest -# -# Label triggers always run on the default Linux runner. Runner selection is -# intentionally restricted to hardcoded aliases in the authorization step. -# -# Setup required before this works: -# 1. Create a `pi-analyze` GitHub environment on the repo and add a -# `PI_AUTH_JSON` secret containing the contents of a pi auth.json -# (~/.pi/agent/auth.json). -# 2. Create the `pi-analyze` label. -# 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to -# read `earendil-works` org membership. The authorization job uses it to -# verify that the label actor is an active member of `earendil-works/staff`. -# 4. Add an environment secret `PI_GIST_TOKEN` on `pi-analyze` with gist -# creation permission. The analysis job uses it to upload the exported -# session gist. -# 5. Add an environment secret `PI_AUTH_UPDATE_TOKEN` on `pi-analyze` with -# permission to update this repo's environment secrets. The analysis job -# uses it to write back refreshed `PI_AUTH_JSON` contents. -# -# The selected runner must have Node.js support plus gh, fd, and ripgrep. GitHub -# hosted runners are bootstrapped below; future self-hosted aliases should have -# those dependencies preinstalled or installable by the setup steps. -# -# The session runs in a high-entropy checkout directory so the recorded cwd is -# a unique string. Import the session into a local checkout with the -# /ir extension command (.pi/extensions/import-repro.ts): -# pi "/ir " - -name: Issue Analysis - -on: - issues: - types: [labeled] - issue_comment: - types: [created] - -permissions: - contents: read - issues: write - -concurrency: - group: issue-analysis-${{ github.event.issue.number }} - cancel-in-progress: false - -jobs: - authorize: - runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.verify.outputs.should_run }} - extra_instructions: ${{ steps.verify.outputs.extra_instructions }} - runs_on: ${{ steps.verify.outputs.runs_on }} - runner_os: ${{ steps.verify.outputs.runner_os }} - runner_profile: ${{ steps.verify.outputs.runner_profile }} - steps: - - name: Verify sender permission - id: verify - uses: actions/github-script@v7 - env: - ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }} - with: - script: | - const ANALYZE_LABEL = 'pi-analyze'; - const TRIGGER_RE = /@issuron\s+analyze\b/i; - const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi; - const RUNNER_PROFILES = { - linux: { runsOn: 'ubuntu-latest', os: 'linux' }, - windows: { runsOn: 'windows-latest', os: 'windows' }, - mac: { runsOn: 'macos-latest', os: 'macos' }, - }; - const RUN_ON_ALIASES = { - linux: 'linux', - ubuntu: 'linux', - 'ubuntu-latest': 'linux', - windows: 'windows', - win: 'windows', - 'windows-latest': 'windows', - mac: 'mac', - macos: 'mac', - darwin: 'mac', - 'macos-latest': 'mac', - }; - - const username = context.payload.sender.login; - let extraInstructions = ''; - let runnerProfile = 'linux'; - - core.setOutput('should_run', 'false'); - core.setOutput('extra_instructions', ''); - core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn)); - core.setOutput('runner_os', RUNNER_PROFILES.linux.os); - core.setOutput('runner_profile', runnerProfile); - - if (context.eventName === 'issues') { - if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) { - console.log('Not a pi-analyze label event'); - return; - } - } else if (context.eventName === 'issue_comment') { - if (context.payload.issue.pull_request) { - console.log('Ignoring pull request comment'); - return; - } - const body = context.payload.comment.body || ''; - if (!TRIGGER_RE.test(body)) { - console.log('Comment does not contain an @issuron analyze trigger'); - return; - } - - const resolvedProfiles = new Set(); - const unknownTags = []; - for (const match of body.matchAll(RUN_ON_TAG_RE)) { - const tag = match[1].toLowerCase(); - const resolved = RUN_ON_ALIASES[tag]; - if (!resolved) { - unknownTags.push(tag); - } else { - resolvedProfiles.add(resolved); - } - } - - if (unknownTags.length > 0) { - core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`); - return; - } - if (resolvedProfiles.size > 1) { - core.setFailed( - `Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles) - .map((profile) => `#run-on-${profile}`) - .join(', ')}`, - ); - return; - } - - runnerProfile = Array.from(resolvedProfiles)[0] || 'linux'; - extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim(); - } else { - console.log(`Unsupported event: ${context.eventName}`); - return; - } - - async function removeTriggerLabel() { - if (context.eventName !== 'issues') return; - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: ANALYZE_LABEL, - }); - } catch (error) { - if (error.status !== 404) throw error; - } - } - - if (!process.env.ORG_READ_TOKEN) { - await removeTriggerLabel(); - core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.'); - return; - } - - try { - const response = await fetch( - `https://api.github.com/orgs/earendil-works/teams/staff/memberships/${encodeURIComponent(username)}`, - { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${process.env.ORG_READ_TOKEN}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - }, - ); - - if (response.status === 404) { - await removeTriggerLabel(); - core.setFailed(`@${username} is not an active earendil-works/staff member.`); - return; - } - - if (!response.ok) { - const body = await response.text(); - await removeTriggerLabel(); - core.setFailed( - `Could not verify earendil-works/staff membership for @${username}: HTTP ${response.status} ${body}`, - ); - return; - } - - const membership = await response.json(); - if (membership.state !== 'active') { - await removeTriggerLabel(); - core.setFailed(`@${username} is not an active earendil-works/staff member.`); - return; - } - console.log(`earendil-works/staff membership for @${username}: ${membership.state}`); - } catch (error) { - await removeTriggerLabel(); - core.setFailed( - `Could not verify earendil-works/staff membership for @${username}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return; - } - - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username, - }); - if (!['admin', 'write'].includes(data.permission)) { - await removeTriggerLabel(); - core.setFailed( - `@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`, - ); - return; - } - - if (context.eventName === 'issue_comment') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: [ANALYZE_LABEL], - }); - } - - const profile = RUNNER_PROFILES[runnerProfile]; - console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`); - core.setOutput('should_run', 'true'); - core.setOutput('extra_instructions', extraInstructions); - core.setOutput('runs_on', JSON.stringify(profile.runsOn)); - core.setOutput('runner_os', profile.os); - core.setOutput('runner_profile', runnerProfile); - - analyze: - needs: authorize - if: needs.authorize.outputs.should_run == 'true' - runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }} - environment: pi-analyze - timeout-minutes: 45 - concurrency: - group: issue-analysis-pi-auth - cancel-in-progress: false - env: - ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5 - ISSUE_ANALYSIS_THINKING: high - steps: - - name: Create high-entropy working directory name - id: workdir - uses: actions/github-script@v7 - with: - script: | - const crypto = require('crypto'); - core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`); - - - name: Checkout - uses: actions/checkout@v4 - with: - path: ${{ steps.workdir.outputs.name }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json - - - name: Install system dependencies (Linux) - if: needs.authorize.outputs.runner_os == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y fd-find ripgrep - sudo ln -sf "$(which fdfind)" /usr/local/bin/fd - - - name: Install system dependencies (macOS) - if: needs.authorize.outputs.runner_os == 'macos' - run: | - if ! command -v fd >/dev/null 2>&1; then - brew install fd - fi - if ! command -v rg >/dev/null 2>&1; then - brew install ripgrep - fi - - - name: Install system dependencies (Windows) - if: needs.authorize.outputs.runner_os == 'windows' - shell: pwsh - run: | - $packages = @() - if (-not (Get-Command fd -ErrorAction SilentlyContinue)) { - $packages += "fd" - } - if (-not (Get-Command rg -ErrorAction SilentlyContinue)) { - $packages += "ripgrep" - } - if ($packages.Count -gt 0) { - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them." - } - choco install $packages -y --no-progress - } - fd --version - rg --version - - - name: Install dependencies - working-directory: ${{ steps.workdir.outputs.name }} - run: npm ci --ignore-scripts - - - name: Build - working-directory: ${{ steps.workdir.outputs.name }} - run: npm run build - - - name: Write auth.json - id: write_auth - uses: actions/github-script@v7 - env: - PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const authJson = process.env.PI_AUTH_JSON; - if (!authJson) { - throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment'); - } - - const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent'); - fs.mkdirSync(agentDir, { recursive: true }); - const authPath = path.join(agentDir, 'auth.json'); - fs.writeFileSync(authPath, authJson, { mode: 0o600 }); - if (process.platform !== 'win32') { - fs.chmodSync(authPath, 0o600); - } - - - name: Run pi /is - uses: actions/github-script@v7 - env: - PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent - GH_TOKEN: ${{ github.token }} - ISSUE_URL: ${{ github.event.issue.html_url }} - EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }} - WORKDIR: ${{ steps.workdir.outputs.name }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - const { spawn } = require('child_process'); - - const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); - const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); - const sessionDir = path.join(outDir, 'session'); - fs.mkdirSync(sessionDir, { recursive: true }); - - let prompt = `/is ${process.env.ISSUE_URL}`; - if (process.env.EXTRA_INSTRUCTIONS) { - prompt += '\n\nAdditional instructions from @issuron analyze comment:\n'; - prompt += process.env.EXTRA_INSTRUCTIONS; - } - - const outputPath = path.join(outDir, 'output.md'); - const output = fs.createWriteStream(outputPath); - const args = [ - 'packages/coding-agent/src/cli.ts', - '-p', - '--approve', - '--session-dir', - sessionDir, - '--model', - process.env.ISSUE_ANALYSIS_MODEL, - '--thinking', - process.env.ISSUE_ANALYSIS_THINKING, - prompt, - ]; - - const exitCode = await new Promise((resolve, reject) => { - const child = spawn('node', args, { - cwd: workdir, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout.on('data', (chunk) => { - process.stdout.write(chunk); - output.write(chunk); - }); - child.stderr.on('data', (chunk) => { - process.stderr.write(chunk); - }); - child.on('error', reject); - child.on('close', resolve); - }); - await new Promise((resolve) => output.end(resolve)); - - if (exitCode !== 0) { - throw new Error(`pi /is failed with exit code ${exitCode}`); - } - - - name: Persist refreshed auth.json - if: always() && steps.write_auth.outcome == 'success' - uses: actions/github-script@v7 - env: - GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }} - PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent - with: - script: | - const fs = require('fs'); - const path = require('path'); - const { spawn } = require('child_process'); - - if (!process.env.GH_TOKEN) { - throw new Error('PI_AUTH_UPDATE_TOKEN is not configured for the pi-analyze environment'); - } - - const authPath = path.join(process.env.PI_CODING_AGENT_DIR, 'auth.json'); - if (!fs.existsSync(authPath)) { - core.warning('auth.json was not created; skipping auth persistence'); - return; - } - - const authJson = fs.readFileSync(authPath, 'utf8'); - let parsed; - try { - parsed = JSON.parse(authJson); - } catch (error) { - throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`); - } - - const codexAuth = parsed['openai-codex']; - if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) { - throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials'); - } - - await new Promise((resolve, reject) => { - const child = spawn( - 'gh', - ['secret', 'set', 'PI_AUTH_JSON', '--env', 'pi-analyze', '--repo', process.env.GITHUB_REPOSITORY], - { env: process.env, stdio: ['pipe', 'inherit', 'inherit'] }, - ); - child.stdin.end(authJson); - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`gh secret set failed with exit code ${code}`)); - } - }); - }); - - - name: Export session files - id: export_session_files - if: always() - uses: actions/github-script@v7 - env: - PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent - WORKDIR: ${{ steps.workdir.outputs.name }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - const { spawn } = require('child_process'); - - function findFirstJsonl(dir) { - if (!fs.existsSync(dir)) return undefined; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const nested = findFirstJsonl(entryPath); - if (nested) return nested; - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - return entryPath; - } - } - return undefined; - } - - const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); - const sessionFile = findFirstJsonl(path.join(outDir, 'session')); - if (!sessionFile) { - throw new Error('No session jsonl file found'); - } - - const sessionJsonl = path.join(outDir, 'session.jsonl'); - const sessionHtml = path.join(outDir, 'session.html'); - fs.copyFileSync(sessionFile, sessionJsonl); - - const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); - const exitCode = await new Promise((resolve, reject) => { - const child = spawn( - 'node', - ['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml], - { cwd: workdir, env: process.env, stdio: 'inherit' }, - ); - child.on('error', reject); - child.on('close', resolve); - }); - if (exitCode !== 0) { - throw new Error(`session export failed with exit code ${exitCode}`); - } - - - name: Upload session gist - id: gist - if: always() && steps.export_session_files.outcome == 'success' - uses: actions/github-script@v7 - env: - PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }} - with: - github-token: ${{ secrets.PI_GIST_TOKEN }} - script: | - const fs = require('fs'); - const path = require('path'); - - if (!process.env.PI_GIST_TOKEN) { - throw new Error('PI_GIST_TOKEN is not configured'); - } - - const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); - const files = {}; - for (const filename of ['session.html', 'session.jsonl']) { - files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') }; - } - - const response = await github.rest.gists.create({ - public: false, - files, - }); - const gistUrl = response.data.html_url; - const gistId = response.data.id; - core.setOutput('url', gistUrl); - core.setOutput('id', gistId); - core.setOutput('share_url', `https://pi.dev/session/#${gistId}`); - - - name: Comment with session import instructions - if: always() && steps.gist.outcome == 'success' - uses: actions/github-script@v7 - env: - GIST_URL: ${{ steps.gist.outputs.url }} - GIST_ID: ${{ steps.gist.outputs.id }} - SHARE_URL: ${{ steps.gist.outputs.share_url }} - SESSION_JSONL: ${{ runner.temp }}/pi-out/session.jsonl - with: - script: | - const fs = require('fs'); - - function extractLastAgentMessage(sessionPath) { - const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean); - let lastText = ''; - - for (const line of lines) { - let entry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - - if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue; - - const content = entry.message.content; - const parts = []; - if (typeof content === 'string') { - parts.push(content); - } else if (Array.isArray(content)) { - for (const block of content) { - if (block?.type === 'text' && typeof block.text === 'string') { - parts.push(block.text); - } - } - } - - const text = parts.join('\n\n').trim(); - if (text) lastText = text; - } - - if (!lastText) return '_No assistant output found._'; - const maxLength = 55000; - if (lastText.length <= maxLength) return lastText; - return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`; - } - - const gistUrl = process.env.GIST_URL; - const gistId = process.env.GIST_ID; - const shareUrl = process.env.SHARE_URL; - const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL); - const body = [ - 'Pi issue analysis finished.', - '', - `Share URL: ${shareUrl}`, - `Gist: ${gistUrl}`, - '', - 'Continue locally from a checkout with:', - '', - '```sh', - `pi "/ir ${gistId}"`, - '```', - '', - '
', - 'Agent analysis summary', - '', - lastAgentMessage, - '', - '
', - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - - - name: Remove trigger label - if: always() - uses: actions/github-script@v7 - with: - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - name: 'pi-analyze', - }); - } catch (error) { - if (error.status !== 404) throw error; - } diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 6fcbfa210..d2bf830ec 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -17,13 +17,11 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); - const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); const issueAuthor = context.payload.issue.user.login; const defaultBranch = context.payload.repository.default_branch; - const isBotAuthor = issueAuthor.endsWith('[bot]'); - if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) { - console.log(`Skipping trusted bot: ${issueAuthor}`); + if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') { + console.log(`Skipping bot: ${issueAuthor}`); return; } @@ -82,7 +80,7 @@ jobs: } const permission = await getPermission(issueAuthor); - if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { + if (['admin', 'maintain', 'write'].includes(permission)) { console.log(`${issueAuthor} is a collaborator with ${permission} access`); return; } @@ -91,7 +89,7 @@ jobs: const approvedUsers = parseApprovedUsers(approvedContent); const capability = approvedUsers.get(issueAuthor.toLowerCase()); - if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) { + if (capability === 'issue' || capability === 'pr') { console.log(`${issueAuthor} is approved for ${capability}`); return; } diff --git a/.github/workflows/openclaw-gate.yml b/.github/workflows/openclaw-gate.yml new file mode 100644 index 000000000..126d4ebf2 --- /dev/null +++ b/.github/workflows/openclaw-gate.yml @@ -0,0 +1,122 @@ +name: OpenClaw Gate + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +jobs: + check-contributor: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Check contributor + uses: actions/github-script@v7 + with: + script: | + const isPR = !!context.payload.pull_request; + const author = isPR + ? context.payload.pull_request.user.login + : context.payload.issue.user.login; + const number = isPR + ? context.payload.pull_request.number + : context.payload.issue.number; + const defaultBranch = context.payload.repository.default_branch; + + if (author.endsWith('[bot]') || author === 'dependabot[bot]') { + console.log(`Skipping bot: ${author}`); + return; + } + + const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; + const VALID_CAPABILITIES = new Set(['issue', 'pr']); + + // --- Check APPROVED_CONTRIBUTORS --- + async function getTextFile(path) { + const { data } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + if (!('content' in data) || typeof data.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + return Buffer.from(data.content, 'base64').toString('utf8'); + } + + try { + const content = await getTextFile(APPROVED_FILE); + const approved = new Map(); + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + if (parts.length !== 2) continue; + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) continue; + + approved.set(username.toLowerCase(), normalizedCapability); + } + + if (approved.has(author.toLowerCase())) { + console.log(`${author} is in APPROVED_CONTRIBUTORS, passing`); + return; + } + } catch (err) { + console.log(`Could not read APPROVED_CONTRIBUTORS: ${err.message}`); + } + + // --- Also pass collaborators with write+ access --- + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: author, + }); + if (['admin', 'maintain', 'write'].includes(perm.permission)) { + console.log(`${author} is a collaborator (${perm.permission}), passing`); + return; + } + } catch { + // not a collaborator + } + + // --- Check if user opened issues/PRs on openclaw/openclaw --- + async function hasOpenClawActivity(username) { + try { + const { data } = await github.rest.search.issuesAndPullRequests({ + q: `repo:openclaw/openclaw author:${username}`, + per_page: 1, + }); + if (data.total_count > 0) { + console.log(`${username} has opened ${data.total_count} issues/PRs on openclaw/openclaw`); + return true; + } + } catch (err) { + console.log(`Search failed: ${err.message}`); + } + return false; + } + + const hasActivity = await hasOpenClawActivity(author); + if (!hasActivity) { + console.log(`${author} has no openclaw/openclaw activity, passing`); + return; + } + + // --- Add openclaw label --- + console.log(`${author} has openclaw/openclaw activity, adding label`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + labels: ['possibly-openclaw-clanker'], + }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 669fd95b1..a62b4afb5 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -18,13 +18,11 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); - const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); const prAuthor = context.payload.pull_request.user.login; const defaultBranch = context.payload.repository.default_branch; - const isBotAuthor = prAuthor.endsWith('[bot]'); - if (TRUSTED_BOT_AUTHORS.has(prAuthor)) { - console.log(`Skipping trusted bot: ${prAuthor}`); + if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') { + console.log(`Skipping bot: ${prAuthor}`); return; } @@ -99,7 +97,7 @@ jobs: } const permission = await getPermission(prAuthor); - if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { + if (['admin', 'maintain', 'write'].includes(permission)) { console.log(`${prAuthor} is a collaborator with ${permission} access`); return; } @@ -108,7 +106,7 @@ jobs: const approvedUsers = parseApprovedUsers(approvedContent); const capability = approvedUsers.get(prAuthor.toLowerCase()); - if (!isBotAuthor && capability === 'pr') { + if (capability === 'pr') { console.log(`${prAuthor} is approved for PRs`); return; } diff --git a/.github/workflows/publish-model-catalog.yml b/.github/workflows/publish-model-catalog.yml deleted file mode 100644 index 14571104a..000000000 --- a/.github/workflows/publish-model-catalog.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Publish Model Catalog - -on: - workflow_run: - workflows: - - CI - types: - - completed - pull_request: - paths: - - '.github/workflows/publish-model-catalog.yml' - - '.gitignore' - - 'package.json' - - 'packages/ai/**' - - 'scripts/publish-model-catalog.mjs' - # GitHub schedules use UTC. Run hourly candidates across the CET/CEST - # boundaries; the publish job only uploads at 10:17, 12:17, and 14:17 - # Europe/Vienna time. - schedule: - - cron: '17 8-13 * * 1-5' - workflow_dispatch: - inputs: - source_ref: - description: 'Commit, branch, or tag to generate from' - required: false - default: 'main' - type: string - publish: - description: 'Upload the generated catalog to production R2' - required: true - default: false - type: boolean - -permissions: - contents: read - -jobs: - generate: - if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }} - runs-on: ubuntu-latest - env: - SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ env.SOURCE_REF }} - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - node-version: '22' - cache: npm - - - name: Install dependencies - run: npm ci --ignore-scripts - - - name: Generate model catalog JSON - run: npm run generate:model-catalog - - - name: Validate model catalog JSON - run: npm run check:model-catalog - - - name: Upload model catalog JSON - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: model-catalog-json - path: .artifacts/model-catalog - if-no-files-found: error - retention-days: 14 - - publish: - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }} - needs: generate - runs-on: ubuntu-latest - environment: pi-model-upload - concurrency: - group: publish-model-catalog-r2 - cancel-in-progress: true - env: - SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} - AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - AWS_EC2_METADATA_DISABLED: 'true' - R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ env.SOURCE_REF }} - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - node-version: '22' - - - name: Download model catalog JSON - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: model-catalog-json - path: .artifacts/model-catalog - - - name: Verify AWS CLI - run: aws --version - - - name: Check publication window - id: publication-window - env: - EVENT_NAME: ${{ github.event_name }} - MANUAL_PUBLISH: ${{ inputs.publish || false }} - run: | - set -euo pipefail - - local_day="$(TZ=Europe/Vienna date +%u)" - local_hour_text="$(TZ=Europe/Vienna date +%H)" - local_hour="$((10#$local_hour_text))" - local_time="$(TZ=Europe/Vienna date '+%Y-%m-%d %H:%M:%S %Z')" - allowed=false - reason="outside the Monday-Friday 10:00-15:00 Europe/Vienna publication window" - - if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PUBLISH" == "true" ]]; then - allowed=true - reason="manual publication" - elif (( local_day <= 5 && local_hour >= 10 && local_hour < 15 )); then - if [[ "$EVENT_NAME" != "schedule" ]] || (( (local_hour - 10) % 2 == 0 )); then - allowed=true - reason="business-hours publication" - else - reason="not a scheduled 10:17, 12:17, or 14:17 Europe/Vienna publication" - fi - fi - - echo "allowed=$allowed" >> "$GITHUB_OUTPUT" - echo "R2 publication allowed: $allowed ($reason; local time: $local_time)" - - - name: Publish model catalog to R2 - if: steps.publication-window.outputs.allowed == 'true' - run: | - node scripts/publish-model-catalog.mjs \ - --input .artifacts/model-catalog \ - --bucket pi-artifacts \ - --endpoint "$R2_ENDPOINT" \ - --source-commit "$(git rev-parse HEAD)" diff --git a/.gitignore b/.gitignore index 8cfb05c71..12a09a8f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/ -.artifacts/ *.log .DS_Store *.tsbuildinfo @@ -8,7 +7,6 @@ dist/ packages/*/dist/ packages/*/dist-chrome/ packages/*/dist-firefox/ -packages/ai/src/providers/data/ *.cpuprofile # Environment diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts deleted file mode 100644 index cb11ca049..000000000 --- a/.pi/extensions/import-repro.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Import a pi session shared as a gist by the issue-analysis CI workflow - * (.github/workflows/issue-analysis.yml) and switch to it. - * - * The CI job runs in a high-entropy checkout directory; this command rewrites - * the recorded cwd to the local checkout, installs the session file into the - * current session directory, and switches to it. - * - * Usage: - * /ir b4d100022aefb12f25dd2d8485e0a82a - * /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a - * /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a - * /ir https://github.com/earendil-works/pi/issues/123 - * - * pi "/ir " - */ - -import { Buffer } from "node:buffer"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, isAbsolute, join, resolve } from "node:path"; -import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; - -const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/; -const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/; -const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/; -const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/; -const GIST_URL_IN_TEXT_RE = /https:\/\/gist\.github\.com\/(?:[^/\s]+\/)?([0-9a-fA-F]{20,})\b/g; -const SESSION_DATA_RE = /