Skip to content

Add Codex Stop-hook plan review #1015

Add Codex Stop-hook plan review

Add Codex Stop-hook plan review #1015

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
pull_request:
branches:
- main
workflow_dispatch:
inputs:
dry-run:
description: Validate build and npm publish without uploading
type: boolean
default: true
permissions:
contents: read
env:
DRY_RUN: ${{ !(startsWith(github.ref, 'refs/tags/') || inputs.dry-run == 'false') }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install
- name: Generate Pi extension shared copies
run: bash apps/pi-extension/vendor.sh
- name: Type check
run: bun run typecheck
- name: Run tests
run: bun test
build:
needs: test
runs-on: ubuntu-latest
# Build job has NO id-token / attestations permissions. Compilation
# itself doesn't need OIDC minting — those capabilities live in the
# separate `attest` job below, which only runs on tag pushes. This
# ensures PR dry-runs (which exercise `bun install` + compile) never
# have OIDC minting available, closing the narrow-but-real
# "trusted-contributor compromise lets a malicious build step mint
# a repo-identity OIDC token" attack surface.
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.11
- name: Install dependencies
run: bun install
- name: Build UI
run: |
bun run build:review
bun run build:hook
- name: Compile binaries (cross-compile all targets)
run: |
# macOS ARM64
bun build apps/hook/server/index.ts --compile --target=bun-darwin-arm64 --outfile plannotator-darwin-arm64
sha256sum plannotator-darwin-arm64 > plannotator-darwin-arm64.sha256
# macOS x64
bun build apps/hook/server/index.ts --compile --target=bun-darwin-x64 --outfile plannotator-darwin-x64
sha256sum plannotator-darwin-x64 > plannotator-darwin-x64.sha256
# Linux x64
bun build apps/hook/server/index.ts --compile --target=bun-linux-x64 --outfile plannotator-linux-x64
sha256sum plannotator-linux-x64 > plannotator-linux-x64.sha256
# Linux ARM64
bun build apps/hook/server/index.ts --compile --target=bun-linux-arm64 --outfile plannotator-linux-arm64
sha256sum plannotator-linux-arm64 > plannotator-linux-arm64.sha256
# Windows x64
bun build apps/hook/server/index.ts --compile --target=bun-windows-x64 --outfile plannotator-win32-x64.exe
sha256sum plannotator-win32-x64.exe > plannotator-win32-x64.exe.sha256
# Windows ARM64 (native, via bun-windows-arm64 — stable since Bun v1.3.10)
bun build apps/hook/server/index.ts --compile --target=bun-windows-arm64 --outfile plannotator-win32-arm64.exe
sha256sum plannotator-win32-arm64.exe > plannotator-win32-arm64.exe.sha256
# Paste service binaries
bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-arm64 --outfile plannotator-paste-darwin-arm64
sha256sum plannotator-paste-darwin-arm64 > plannotator-paste-darwin-arm64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-darwin-x64 --outfile plannotator-paste-darwin-x64
sha256sum plannotator-paste-darwin-x64 > plannotator-paste-darwin-x64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-linux-x64 --outfile plannotator-paste-linux-x64
sha256sum plannotator-paste-linux-x64 > plannotator-paste-linux-x64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-linux-arm64 --outfile plannotator-paste-linux-arm64
sha256sum plannotator-paste-linux-arm64 > plannotator-paste-linux-arm64.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-windows-x64 --outfile plannotator-paste-win32-x64.exe
sha256sum plannotator-paste-win32-x64.exe > plannotator-paste-win32-x64.exe.sha256
bun build apps/paste-service/targets/bun.ts --compile --target=bun-windows-arm64 --outfile plannotator-paste-win32-arm64.exe
sha256sum plannotator-paste-win32-arm64.exe > plannotator-paste-win32-arm64.exe.sha256
- name: Upload artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: binaries
path: |
plannotator-*
!*.ts
smoke-binaries:
needs: build
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
binary: plannotator-linux-x64
- os: windows-latest
binary: plannotator-win32-x64.exe
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries
path: artifacts
- name: Smoke-test binary
if: runner.os != 'Windows'
env:
BINARY: artifacts/${{ matrix.binary }}
BROWSER: true
run: |
set -euo pipefail
chmod +x "$BINARY"
# 1. --help: proves binary loads and arg parsing works.
"$BINARY" --help
smoke_test_server() {
local label="$1" port="$2" endpoint="$3"
shift 3
PLANNOTATOR_PORT="$port" "$@" &
local pid=$!
local ok=0
for _ in $(seq 1 60); do
if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then
ok=1
break
fi
sleep 0.5
done
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
if [ "$ok" = "0" ]; then
echo "FAIL: ${label} did not respond on :${port}${endpoint}"
exit 1
fi
echo "OK: ${label} responded on :${port}${endpoint}"
}
# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
smoke_test_server "plannotator review" 19500 "/api/diff" \
"$BINARY" review
# 3. annotate: exercises annotate server startup with a real file.
smoke_test_server "plannotator annotate" 19501 "/api/plan" \
"$BINARY" annotate README.md
- name: Smoke-test binary
if: runner.os == 'Windows'
shell: pwsh
env:
BINARY: artifacts/${{ matrix.binary }}
BROWSER: true
run: |
$ErrorActionPreference = "Stop"
$binary = (Resolve-Path $env:BINARY).Path
# 1. --help: proves binary loads and arg parsing works.
& $binary --help
function Test-PlannotatorServer {
param(
[string] $Label,
[string] $Port,
[string] $Endpoint,
[string[]] $Arguments
)
$env:PLANNOTATOR_PORT = $Port
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$process = Start-Process `
-FilePath $binary `
-ArgumentList $Arguments `
-PassThru `
-NoNewWindow `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
$ok = $false
try {
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null
$ok = $true
break
} catch {
if ($process.HasExited) {
break
}
Start-Sleep -Milliseconds 500
}
}
} finally {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
}
Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue
}
if (-not $ok) {
Write-Host "stdout:"
Get-Content $stdout -ErrorAction SilentlyContinue
Write-Host "stderr:"
Get-Content $stderr -ErrorAction SilentlyContinue
throw "FAIL: $Label did not respond on :$Port$Endpoint"
}
Write-Host "OK: $Label responded on :$Port$Endpoint"
}
# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
Test-PlannotatorServer "plannotator review" "19500" "/api/diff" @("review")
# 3. annotate: exercises annotate server startup with a real file.
Test-PlannotatorServer "plannotator annotate" "19501" "/api/plan" @("annotate", "README.md")
install-script-smoke:
needs: build
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
artifact: plannotator-linux-x64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries
path: artifacts
- name: Verify installer writes Codex hook config
env:
ARTIFACT_NAME: ${{ matrix.artifact }}
run: |
set -euo pipefail
tmp_home="$(mktemp -d)"
fake_bin="$(mktemp -d)"
artifact="$PWD/artifacts/$ARTIFACT_NAME"
cat > "$fake_bin/codex" <<'SH'
#!/usr/bin/env bash
echo "codex stub"
SH
chmod +x "$fake_bin/codex"
cat > "$fake_bin/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
out=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o|--output)
out="$2"
shift 2
;;
-*)
shift
;;
*)
url="$1"
shift
;;
esac
done
if [[ "$url" == *.sha256 ]]; then
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$ARTIFACT"
else
shasum -a 256 "$ARTIFACT"
fi
exit 0
fi
if [ -n "$out" ]; then
cp "$ARTIFACT" "$out"
else
cat "$ARTIFACT"
fi
SH
chmod +x "$fake_bin/curl"
run_installer() {
HOME="$tmp_home" \
PATH="$fake_bin:$PATH" \
SHELL=/bin/bash \
ARTIFACT="$artifact" \
bash scripts/install.sh --version v9.9.9 --skip-attestation
}
run_installer
test -x "$tmp_home/.local/bin/plannotator"
grep -q 'codex_hooks = true' "$tmp_home/.codex/config.toml"
HOME="$tmp_home" node <<'NODE'
const fs = require("fs");
const path = require("path");
const home = process.env.HOME;
const hooksPath = path.join(home, ".codex", "hooks.json");
const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
const command = hooks?.hooks?.Stop?.[0]?.hooks?.[0]?.command;
const timeout = hooks?.hooks?.Stop?.[0]?.hooks?.[0]?.timeout;
const expected = path.join(home, ".local", "bin", "plannotator");
if (command !== expected) {
throw new Error(`Expected Stop hook command ${expected}, got ${command}`);
}
if (timeout !== 345600) {
throw new Error(`Expected Stop hook timeout 345600, got ${timeout}`);
}
NODE
cat > "$tmp_home/.codex/hooks.json" <<'JSON'
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "PLANNOTATOR_BROWSER=/usr/bin/true plannotator",
"timeout": 123
}
]
}
]
}
}
JSON
run_installer
HOME="$tmp_home" node <<'NODE'
const fs = require("fs");
const path = require("path");
const hooksPath = path.join(process.env.HOME, ".codex", "hooks.json");
const stop = JSON.parse(fs.readFileSync(hooksPath, "utf8"))?.hooks?.Stop;
const hooks = stop?.flatMap((entry) => entry?.hooks ?? []) ?? [];
if (hooks.length !== 1) {
throw new Error(`Expected one preserved custom Stop hook, got ${hooks.length}`);
}
if (hooks[0].command !== "PLANNOTATOR_BROWSER=/usr/bin/true plannotator") {
throw new Error(`Custom Stop hook command was changed to ${hooks[0].command}`);
}
NODE
attest:
# Isolated attestation job — runs on tag pushes only and holds the
# OIDC minting + attestations-write capabilities that the build job
# used to have. Splitting this out means PR builds and non-tag pushes
# never get id-token: write granted, closing the trusted-contributor
# compromise window where a malicious build step could mint a
# repo-identity OIDC token. The attestation is produced against the
# same binaries the build job uploaded; attest-build-provenance
# publishes the signed bundle to GitHub's attestation store, so the
# release job downstream doesn't need any new artifact handling.
needs:
- build
- smoke-binaries
- install-script-smoke
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries
- name: Generate SLSA build provenance attestation
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
plannotator-darwin-arm64
plannotator-darwin-x64
plannotator-linux-x64
plannotator-linux-arm64
plannotator-win32-x64.exe
plannotator-win32-arm64.exe
plannotator-paste-darwin-arm64
plannotator-paste-darwin-x64
plannotator-paste-linux-x64
plannotator-paste-linux-arm64
plannotator-paste-win32-x64.exe
plannotator-paste-win32-arm64.exe
release:
# Depends on `attest` so the signed provenance exists before the
# GitHub Release is published — otherwise there'd be a window where
# users could pull the binary and `gh attestation verify` would
# race-fail. `needs: attest` implicitly requires `build` too.
needs: attest
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries
path: artifacts
- name: List artifacts
run: ls -la artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: artifacts/*
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, '-') }}
npm-publish:
needs:
- build
- smoke-binaries
- install-script-smoke
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.11
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: bun install
- name: Build packages
run: |
bun run build:review
bun run build:hook
bun run build:opencode
bun run build:pi
- name: Publish @plannotator/opencode
working-directory: apps/opencode-plugin
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun pm pack
if [[ "$DRY_RUN" == "false" ]]; then
npm publish *.tgz --provenance --access public
fi
- name: Publish @plannotator/pi-extension
working-directory: apps/pi-extension
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun pm pack
if [[ "$DRY_RUN" == "false" ]]; then
npm publish *.tgz --provenance --access public
fi