mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
1192 lines
46 KiB
YAML
1192 lines
46 KiB
YAML
name: Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
pull_request:
|
|
branches:
|
|
- main
|
|
workflow_dispatch:
|
|
inputs:
|
|
dry-run:
|
|
description: Package only; disable only when dispatching a v* tag to publish
|
|
type: boolean
|
|
default: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
# A release tag must identify a commit already contained in main. This runs
|
|
# independently of the build graph so an invalid tag cannot attest, publish,
|
|
# or create a GitHub release even if the build itself succeeds.
|
|
release-eligibility:
|
|
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs['dry-run'] == 'false'))
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Require release commit to be on main
|
|
shell: bash
|
|
run: |
|
|
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
|
release_commit="$(git rev-parse "${GITHUB_REF}^{commit}")"
|
|
if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then
|
|
echo "Release tag ${GITHUB_REF_NAME} points to ${release_commit}, which is not contained in main." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Require tag to match release manifests
|
|
env:
|
|
RELEASE_TAG: ${{ github.ref_name }}
|
|
run: node scripts/check-release-version.mjs --tag "$RELEASE_TAG"
|
|
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
with:
|
|
bun-version: 1.3.14
|
|
no-cache: true
|
|
|
|
- name: Install dependencies
|
|
run: bun install --frozen-lockfile
|
|
|
|
- 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
with:
|
|
bun-version: 1.3.14
|
|
no-cache: true
|
|
|
|
- name: Install dependencies
|
|
run: bun install --frozen-lockfile
|
|
|
|
- name: Build UI
|
|
run: |
|
|
bun run build:review
|
|
bun run build:hook
|
|
|
|
- name: Compile binaries (cross-compile all targets)
|
|
run: |
|
|
VERSION=$(jq -r .version package.json)
|
|
|
|
# macOS ARM64
|
|
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-darwin-arm64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-darwin-arm64
|
|
sha256sum plannotator-darwin-arm64 > plannotator-darwin-arm64.sha256
|
|
|
|
# macOS x64
|
|
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-darwin-x64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-darwin-x64
|
|
sha256sum plannotator-darwin-x64 > plannotator-darwin-x64.sha256
|
|
|
|
# Linux x64
|
|
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-x64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-linux-x64
|
|
sha256sum plannotator-linux-x64 > plannotator-linux-x64.sha256
|
|
|
|
# Linux ARM64
|
|
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-arm64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-linux-arm64
|
|
sha256sum plannotator-linux-arm64 > plannotator-linux-arm64.sha256
|
|
|
|
# Windows x64
|
|
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-windows-x64 --define "__CLI_VERSION__=\"$VERSION\"" --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 --no-compile-autoload-bunfig --target=bun-windows-arm64 --define "__CLI_VERSION__=\"$VERSION\"" --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 --no-compile-autoload-bunfig --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 --no-compile-autoload-bunfig --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 --no-compile-autoload-bunfig --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 --no-compile-autoload-bunfig --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 --no-compile-autoload-bunfig --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 --no-compile-autoload-bunfig --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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
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
|
|
# macOS leg exists so a Bun cross-compile signing regression (the
|
|
# 1.3.12 linker-signature loss, #541: binaries SIGKILLed on launch)
|
|
# can never ship silently again. Executing the binary at all is the
|
|
# assertion; the env smoke below rides along.
|
|
- os: macos-latest
|
|
binary: plannotator-darwin-arm64
|
|
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Download binaries
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: binaries
|
|
path: artifacts
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 24
|
|
package-manager-cache: false
|
|
|
|
- name: Smoke-test binary
|
|
if: runner.os != 'Windows'
|
|
env:
|
|
BINARY: artifacts/${{ matrix.binary }}
|
|
BROWSER: true
|
|
run: |
|
|
set -euo pipefail
|
|
chmod +x "$BINARY"
|
|
|
|
# 1. --help from a hostile caller cwd proves the binary loads,
|
|
# arg parsing works, and Bun did not autoload caller bunfig.toml.
|
|
hostile_cwd="$(mktemp -d)"
|
|
printf 'preload = ["./does-not-exist.ts"]\n' > "$hostile_cwd/bunfig.toml"
|
|
(
|
|
cd "$hostile_cwd"
|
|
"$GITHUB_WORKSPACE/$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}"
|
|
}
|
|
|
|
smoke_test_agent_terminal() {
|
|
local port="19502"
|
|
local data_dir
|
|
data_dir="$(mktemp -d)"
|
|
|
|
PLANNOTATOR_DATA_DIR="$data_dir" "$BINARY" install-runtime agent-terminal
|
|
|
|
PLANNOTATOR_DATA_DIR="$data_dir" PLANNOTATOR_PORT="$port" "$BINARY" annotate README.md &
|
|
local pid=$!
|
|
local ok=0
|
|
|
|
for _ in $(seq 1 60); do
|
|
if curl -sf "http://127.0.0.1:${port}/api/plan" -o /dev/null 2>/dev/null; then
|
|
ok=1
|
|
break
|
|
fi
|
|
sleep 0.5
|
|
done
|
|
|
|
if [ "$ok" = "0" ]; then
|
|
kill "$pid" 2>/dev/null || true
|
|
wait "$pid" 2>/dev/null || true
|
|
echo "FAIL: plannotator annotate terminal did not respond on :${port}/api/plan"
|
|
exit 1
|
|
fi
|
|
|
|
local runtime_dir="${data_dir}/vendor/agent-terminal/webtui-0.1.0"
|
|
local smoke_script="${runtime_dir}/terminal-smoke.mjs"
|
|
cat > "$smoke_script" <<'NODE'
|
|
import WebSocket from "ws";
|
|
|
|
const base = process.env.PLANNOTATOR_SMOKE_URL;
|
|
const plan = await fetch(`${base}/api/plan`).then((res) => res.json());
|
|
const terminal = plan.agentTerminal;
|
|
if (!terminal?.enabled) {
|
|
throw new Error(`agent terminal disabled: ${JSON.stringify(terminal)}`);
|
|
}
|
|
if (!terminal.wsPath?.startsWith("/api/agent-terminal/pty/")) {
|
|
throw new Error(`agent terminal returned non-tokenized path: ${terminal.wsPath}`);
|
|
}
|
|
|
|
await new Promise((resolve, reject) => {
|
|
let ws;
|
|
const timer = setTimeout(() => {
|
|
ws?.close();
|
|
reject(new Error("timed out waiting for terminal response"));
|
|
}, 5000);
|
|
ws = new WebSocket(`${base.replace(/^http/, "ws")}${terminal.wsPath}`);
|
|
ws.on("open", () => {
|
|
ws.send(JSON.stringify({ type: "spawn", requestId: "missing-agent", options: {} }));
|
|
});
|
|
ws.on("message", (raw) => {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
const message = JSON.parse(String(raw));
|
|
if (message.type !== "error" || message.message !== "Agent terminal requires a built-in WebTUI agent.") {
|
|
reject(new Error(`unexpected terminal response: ${JSON.stringify(message)}`));
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
ws.on("error", (err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
NODE
|
|
|
|
local result=0
|
|
(cd "$runtime_dir" && PLANNOTATOR_SMOKE_URL="http://127.0.0.1:${port}" node "$smoke_script") || result=$?
|
|
kill "$pid" 2>/dev/null || true
|
|
wait "$pid" 2>/dev/null || true
|
|
if [ "$result" != "0" ]; then
|
|
exit "$result"
|
|
fi
|
|
|
|
echo "OK: plannotator annotate terminal WebSocket reached sidecar"
|
|
}
|
|
|
|
# 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
|
|
|
|
# 4. terminal: exercises managed WebTUI runtime + compiled sidecar import.
|
|
smoke_test_agent_terminal
|
|
|
|
# 5. #1249 regression: env vars must load even when a cwd ancestor
|
|
# is unreadable (the OS-sandbox case: the project dir is granted but
|
|
# an ancestor is not). Bun <= 1.3.11 loaded an EMPTY process.env
|
|
# there, silently ignoring every PLANNOTATOR_* variable. The server
|
|
# responding on the fixed PLANNOTATOR_PORT proves the variable was
|
|
# actually read from inside the guarded cwd.
|
|
guard_root="$(mktemp -d)"
|
|
mkdir -p "$guard_root/parent/child"
|
|
printf '# env smoke\n' > "$guard_root/parent/child/doc.md"
|
|
chmod 311 "$guard_root/parent"
|
|
(
|
|
cd "$guard_root/parent/child"
|
|
smoke_test_server "annotate under unreadable ancestor" 19503 "/api/plan" \
|
|
"$GITHUB_WORKSPACE/$BINARY" annotate doc.md
|
|
)
|
|
chmod 755 "$guard_root/parent"
|
|
|
|
- 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 from a hostile caller cwd proves the binary loads,
|
|
# arg parsing works, and Bun did not autoload caller bunfig.toml.
|
|
$hostileCwd = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()))
|
|
Set-Content -Path (Join-Path $hostileCwd.FullName "bunfig.toml") -Value 'preload = ["./does-not-exist.ts"]'
|
|
Push-Location $hostileCwd.FullName
|
|
try {
|
|
& $binary --help
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
function Test-AgentTerminal {
|
|
$port = "19502"
|
|
$dataDir = Join-Path ([System.IO.Path]::GetTempPath()) "plannotator-terminal-$([System.Guid]::NewGuid().ToString('N'))"
|
|
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
|
|
|
$env:PLANNOTATOR_DATA_DIR = $dataDir
|
|
& $binary install-runtime agent-terminal
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "FAIL: install-runtime agent-terminal failed"
|
|
}
|
|
|
|
$env:PLANNOTATOR_PORT = $port
|
|
$stdout = New-TemporaryFile
|
|
$stderr = New-TemporaryFile
|
|
$process = Start-Process `
|
|
-FilePath $binary `
|
|
-ArgumentList @("annotate", "README.md") `
|
|
-PassThru `
|
|
-NoNewWindow `
|
|
-RedirectStandardOutput $stdout `
|
|
-RedirectStandardError $stderr
|
|
|
|
try {
|
|
$ok = $false
|
|
for ($i = 0; $i -lt 60; $i++) {
|
|
try {
|
|
Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/plan" -UseBasicParsing -TimeoutSec 1 | Out-Null
|
|
$ok = $true
|
|
break
|
|
} catch {
|
|
if ($process.HasExited) {
|
|
break
|
|
}
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
|
|
if (-not $ok) {
|
|
throw "FAIL: plannotator annotate terminal did not respond on :$Port/api/plan"
|
|
}
|
|
|
|
$runtimeDir = Join-Path $dataDir "vendor\agent-terminal\webtui-0.1.0"
|
|
$smokeScript = Join-Path $runtimeDir "terminal-smoke.mjs"
|
|
Set-Content -Path $smokeScript -Encoding UTF8 -Value @'
|
|
import WebSocket from "ws";
|
|
|
|
const base = process.env.PLANNOTATOR_SMOKE_URL;
|
|
const plan = await fetch(`${base}/api/plan`).then((res) => res.json());
|
|
const terminal = plan.agentTerminal;
|
|
if (!terminal?.enabled) {
|
|
throw new Error(`agent terminal disabled: ${JSON.stringify(terminal)}`);
|
|
}
|
|
if (!terminal.wsPath?.startsWith("/api/agent-terminal/pty/")) {
|
|
throw new Error(`agent terminal returned non-tokenized path: ${terminal.wsPath}`);
|
|
}
|
|
|
|
await new Promise((resolve, reject) => {
|
|
let ws;
|
|
const timer = setTimeout(() => {
|
|
ws?.close();
|
|
reject(new Error("timed out waiting for terminal response"));
|
|
}, 5000);
|
|
ws = new WebSocket(`${base.replace(/^http/, "ws")}${terminal.wsPath}`);
|
|
ws.on("open", () => {
|
|
ws.send(JSON.stringify({ type: "spawn", requestId: "missing-agent", options: {} }));
|
|
});
|
|
ws.on("message", (raw) => {
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
const message = JSON.parse(String(raw));
|
|
if (message.type !== "error" || message.message !== "Agent terminal requires a built-in WebTUI agent.") {
|
|
reject(new Error(`unexpected terminal response: ${JSON.stringify(message)}`));
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
ws.on("error", (err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
'@
|
|
Push-Location $runtimeDir
|
|
try {
|
|
$env:PLANNOTATOR_SMOKE_URL = "http://127.0.0.1:$Port"
|
|
node $smokeScript
|
|
} finally {
|
|
Pop-Location
|
|
Remove-Item Env:\PLANNOTATOR_SMOKE_URL -ErrorAction SilentlyContinue
|
|
}
|
|
Write-Host "OK: plannotator annotate terminal WebSocket reached sidecar"
|
|
} 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
|
|
Remove-Item Env:\PLANNOTATOR_DATA_DIR -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# 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")
|
|
|
|
# 4. terminal: exercises managed WebTUI runtime + compiled sidecar import.
|
|
Test-AgentTerminal
|
|
|
|
- name: Smoke-test uninstall lifecycle
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
env:
|
|
BINARY: artifacts/${{ matrix.binary }}
|
|
run: |
|
|
$ErrorActionPreference = "Stop"
|
|
# This disposable GitHub-hosted VM is the real Windows QA boundary.
|
|
# The smoke temporarily adds one unique RUNNER_TEMP path to HKCU PATH,
|
|
# restores the original value in finally, and the VM is discarded.
|
|
$sourceBinary = (Resolve-Path $env:BINARY).Path
|
|
$runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP)
|
|
$testRoot = [System.IO.Path]::GetFullPath((Join-Path $runnerTemp "plannotator-uninstall-$([System.Guid]::NewGuid().ToString('N'))"))
|
|
$expectedPrefix = $runnerTemp.TrimEnd('\') + '\'
|
|
if (-not $testRoot.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
throw "Refusing unsafe uninstall smoke root: $testRoot"
|
|
}
|
|
|
|
$environmentNames = @(
|
|
"USERPROFILE",
|
|
"LOCALAPPDATA",
|
|
"APPDATA",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_CACHE_HOME",
|
|
"CLAUDE_CONFIG_DIR",
|
|
"CODEX_HOME",
|
|
"FACTORY_CONFIG_DIR",
|
|
"COPILOT_HOME",
|
|
"PI_CODING_AGENT_DIR",
|
|
"PLANNOTATOR_DATA_DIR"
|
|
)
|
|
$savedEnvironment = @{}
|
|
foreach ($name in $environmentNames) {
|
|
$savedEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name, "Process")
|
|
}
|
|
$originalUserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
|
|
function Wait-UntilMissing {
|
|
param([string] $Path)
|
|
for ($i = 0; $i -lt 80; $i++) {
|
|
if (-not (Test-Path -LiteralPath $Path)) { return }
|
|
Start-Sleep -Milliseconds 250
|
|
}
|
|
throw "Timed out waiting for uninstall to remove $Path"
|
|
}
|
|
|
|
function Set-IsolatedUninstallEnvironment {
|
|
param([string] $CaseRoot)
|
|
$isolatedHome = Join-Path $CaseRoot "home"
|
|
$localAppData = Join-Path $isolatedHome "AppData\Local"
|
|
$values = @{
|
|
USERPROFILE = $isolatedHome
|
|
LOCALAPPDATA = $localAppData
|
|
APPDATA = (Join-Path $isolatedHome "AppData\Roaming")
|
|
XDG_CONFIG_HOME = (Join-Path $isolatedHome ".config")
|
|
XDG_CACHE_HOME = (Join-Path $isolatedHome ".cache")
|
|
CLAUDE_CONFIG_DIR = (Join-Path $isolatedHome ".claude")
|
|
CODEX_HOME = (Join-Path $isolatedHome ".codex")
|
|
FACTORY_CONFIG_DIR = (Join-Path $isolatedHome ".factory")
|
|
COPILOT_HOME = (Join-Path $isolatedHome ".copilot")
|
|
PI_CODING_AGENT_DIR = (Join-Path $isolatedHome ".pi\agent")
|
|
PLANNOTATOR_DATA_DIR = (Join-Path $isolatedHome ".plannotator")
|
|
}
|
|
foreach ($entry in $values.GetEnumerator()) {
|
|
[System.Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process")
|
|
}
|
|
return $values
|
|
}
|
|
|
|
function Add-TestUserPathEntry {
|
|
param([string] $InstallDirectory)
|
|
$current = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
$next = if ([string]::IsNullOrWhiteSpace($current)) {
|
|
";$InstallDirectory;"
|
|
} else {
|
|
"$current;;$InstallDirectory;"
|
|
}
|
|
[System.Environment]::SetEnvironmentVariable("Path", $next, "User")
|
|
if ([string]::IsNullOrWhiteSpace($current)) { return ";" }
|
|
return "$current;;"
|
|
}
|
|
|
|
function Assert-TestUserPathEntryRemoved {
|
|
param(
|
|
[string] $InstallDirectory,
|
|
[string] $ExpectedPath
|
|
)
|
|
$current = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
$target = $InstallDirectory.Trim().TrimEnd('\')
|
|
$matches = @($current -split ';' | Where-Object {
|
|
$_ -and $_.Trim().TrimEnd('\') -ieq $target
|
|
})
|
|
if ($matches.Count -ne 0) {
|
|
throw "Uninstall left its user PATH entry behind: $InstallDirectory"
|
|
}
|
|
if ($current -cne $ExpectedPath) {
|
|
throw "Uninstall changed unrelated user PATH bytes. Expected '$ExpectedPath', got '$current'"
|
|
}
|
|
}
|
|
|
|
function Invoke-UninstallSmoke {
|
|
param(
|
|
[string] $Name,
|
|
[bool] $Purge
|
|
)
|
|
$caseRoot = Join-Path $testRoot $Name
|
|
$values = Set-IsolatedUninstallEnvironment $caseRoot
|
|
$installDirectory = Join-Path $values.LOCALAPPDATA "plannotator"
|
|
$testBinary = Join-Path $installDirectory "plannotator.exe"
|
|
$dataDirectory = $values.PLANNOTATOR_DATA_DIR
|
|
|
|
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
|
|
Copy-Item -LiteralPath $sourceBinary -Destination $testBinary
|
|
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "plans") | Out-Null
|
|
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "vendor\sem\v-test") | Out-Null
|
|
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "vendor\agent-terminal\v-test") | Out-Null
|
|
Set-Content -LiteralPath (Join-Path $dataDirectory "plans\keep.md") -Value "# keep"
|
|
Set-Content -LiteralPath (Join-Path $dataDirectory "config.json") -Value '{}'
|
|
Set-Content -LiteralPath (Join-Path $dataDirectory "vendor\sem\v-test\sem.exe") -Value "fixture"
|
|
Set-Content -LiteralPath (Join-Path $dataDirectory "vendor\agent-terminal\v-test\server.js") -Value "fixture"
|
|
$expectedUserPath = Add-TestUserPathEntry $installDirectory
|
|
|
|
$arguments = @("uninstall", "--yes")
|
|
if ($Purge) { $arguments += "--purge" }
|
|
& $testBinary @arguments
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Uninstall smoke '$Name' exited $LASTEXITCODE"
|
|
}
|
|
|
|
Wait-UntilMissing $testBinary
|
|
Wait-UntilMissing $installDirectory
|
|
Assert-TestUserPathEntryRemoved $installDirectory $expectedUserPath
|
|
|
|
if ($Purge) {
|
|
if (Test-Path -LiteralPath $dataDirectory) {
|
|
throw "Purge smoke left the data directory behind: $dataDirectory"
|
|
}
|
|
} else {
|
|
if (-not (Test-Path -LiteralPath (Join-Path $dataDirectory "plans\keep.md"))) {
|
|
throw "Default uninstall removed preserved plan data"
|
|
}
|
|
if (-not (Test-Path -LiteralPath (Join-Path $dataDirectory "config.json"))) {
|
|
throw "Default uninstall removed preserved configuration"
|
|
}
|
|
if (Test-Path -LiteralPath (Join-Path $dataDirectory "vendor\sem")) {
|
|
throw "Default uninstall left the sem sidecar behind"
|
|
}
|
|
if (Test-Path -LiteralPath (Join-Path $dataDirectory "vendor\agent-terminal")) {
|
|
throw "Default uninstall left the agent-terminal runtime behind"
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
Invoke-UninstallSmoke "preserve" $false
|
|
Invoke-UninstallSmoke "purge" $true
|
|
Write-Host "OK: Windows uninstall preserve-data, purge, PATH, and self-delete verified"
|
|
} finally {
|
|
[System.Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
|
|
foreach ($name in $environmentNames) {
|
|
[System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process")
|
|
}
|
|
if (Test-Path -LiteralPath $testRoot) {
|
|
Remove-Item -LiteralPath $testRoot -Recurse -Force
|
|
}
|
|
}
|
|
|
|
pi-extension-ai-runtime-windows:
|
|
needs: test
|
|
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
|
|
# npm-style `pi` shim pair. The binary smoke above covers the compiled Bun
|
|
# CLI, but the published Pi extension uses this separate Node path.
|
|
name: Pi extension AI runtime (Windows)
|
|
runs-on: windows-latest
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
with:
|
|
bun-version: 1.3.14
|
|
no-cache: true
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 24
|
|
package-manager-cache: false
|
|
|
|
- name: Install dependencies
|
|
run: bun install --frozen-lockfile
|
|
|
|
- name: Generate Pi extension shared copies
|
|
shell: bash
|
|
run: bash apps/pi-extension/vendor.sh
|
|
|
|
- name: Build Pi AI runtime smoke
|
|
run: bun build scripts/smoke-pi-extension-ai-runtime.ts --target=node --outfile "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"
|
|
|
|
- name: Run Pi AI runtime smoke
|
|
run: node "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"
|
|
|
|
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- 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"
|
|
|
|
# --skip-skills: this smoke installs a SYNTHETIC v9.9.9. The fake curl
|
|
# above serves the freshly built binary for any URL, but the skills and
|
|
# slash commands come from `git clone --branch v9.9.9` against real
|
|
# github.com, where that tag does not and cannot exist. The clone has
|
|
# always failed here; it only went unnoticed while a broken guard let
|
|
# the installer exit 0 anyway. This job checks the Codex hook config,
|
|
# not the skills, so opt out of the checkout rather than teach the test
|
|
# to ignore a real error. Both call sites below run through this
|
|
# function, so the flag applies to each of them.
|
|
run_installer() {
|
|
HOME="$tmp_home" \
|
|
PATH="$fake_bin:$PATH" \
|
|
SHELL=/bin/bash \
|
|
ARTIFACT="$artifact" \
|
|
bash scripts/install.sh --version v9.9.9 --skip-attestation --skip-skills
|
|
}
|
|
|
|
run_installer
|
|
|
|
test -x "$tmp_home/.local/bin/plannotator"
|
|
grep -q '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. It rechecks the security gate's
|
|
# subject digests, preserves attest-build-provenance for native binaries,
|
|
# and adds the release-wide CycloneDX predicate for every shipped subject.
|
|
needs:
|
|
- release-eligibility
|
|
- build
|
|
- smoke-binaries
|
|
- pi-extension-ai-runtime-windows
|
|
- install-script-smoke
|
|
- release-security
|
|
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs['dry-run'] == 'false'))
|
|
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
|
|
digest-mismatch: error
|
|
|
|
- name: Download npm package subjects
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: npm-packages
|
|
path: npm-packages
|
|
digest-mismatch: error
|
|
|
|
- name: Download validated subject evidence
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: release-security-subjects
|
|
path: release-security-subjects
|
|
digest-mismatch: error
|
|
|
|
- name: Download public release SBOM
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: release-sbom
|
|
path: release-sbom
|
|
digest-mismatch: error
|
|
|
|
- name: Revalidate gated release subjects
|
|
run: sha256sum --check --strict release-security-subjects/subject-checksums.sha256
|
|
|
|
- name: Generate SLSA build provenance attestation
|
|
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
|
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
|
|
|
|
# actions/attest is the current official general attestation action.
|
|
# Its sbom-path mode supersedes the deprecated actions/attest-sbom
|
|
# wrapper and binds the CycloneDX predicate to the shipped subjects,
|
|
# not to the SBOM file itself.
|
|
- name: Generate release SBOM attestation
|
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
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
|
|
npm-packages/plannotator-opencode.tgz
|
|
npm-packages/plannotator-pi-extension.tgz
|
|
sbom-path: release-sbom/${{ needs.release-security.outputs.sbom-name }}
|
|
|
|
release:
|
|
# Depends on `attest` so the signed provenance and SBOM attestation exist 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/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs['dry-run'] == 'false'))
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Download artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: binaries
|
|
path: artifacts
|
|
|
|
- name: Download release SBOM
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: release-sbom
|
|
path: artifacts
|
|
digest-mismatch: error
|
|
|
|
- name: List artifacts
|
|
run: ls -la artifacts/
|
|
|
|
- name: Create GitHub Release
|
|
# zizmor: ignore[superfluous-actions] The pinned action preserves the
|
|
# existing release behavior. Owner: maintainers; reassess replacement
|
|
# with gh by 2027-08-11.
|
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
|
with:
|
|
files: artifacts/*
|
|
generate_release_notes: true
|
|
draft: false
|
|
prerelease: ${{ contains(github.ref, '-') }}
|
|
|
|
# PRs and manual dry-runs stop here. This job executes repository build code,
|
|
# so it must never receive an npm token or permission to mint an OIDC token.
|
|
npm-package:
|
|
needs:
|
|
- build
|
|
- smoke-binaries
|
|
- pi-extension-ai-runtime-windows
|
|
- install-script-smoke
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
with:
|
|
bun-version: 1.3.14
|
|
no-cache: true
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 24
|
|
package-manager-cache: false
|
|
registry-url: https://registry.npmjs.org
|
|
|
|
- name: Install dependencies
|
|
run: bun install --frozen-lockfile
|
|
|
|
- name: Build packages
|
|
run: |
|
|
bun run build:review
|
|
bun run build:hook
|
|
bun run build:opencode
|
|
bun run build:pi
|
|
|
|
- name: Create package artifact directory
|
|
run: mkdir -p npm-packages
|
|
|
|
- name: Pack @plannotator/opencode
|
|
working-directory: apps/opencode-plugin
|
|
run: bun pm pack --filename "$GITHUB_WORKSPACE/npm-packages/plannotator-opencode.tgz"
|
|
|
|
- name: Pack @plannotator/pi-extension
|
|
working-directory: apps/pi-extension
|
|
run: bun pm pack --filename "$GITHUB_WORKSPACE/npm-packages/plannotator-pi-extension.tgz"
|
|
|
|
- name: Upload npm package artifacts
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: npm-packages
|
|
path: npm-packages/*.tgz
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
# Credential-free release inventory and vulnerability gate. This job runs on
|
|
# PRs and dry-runs against the exact binaries and npm tarballs produced above,
|
|
# but it cannot attest, publish, deploy, or mint an OIDC token.
|
|
release-security:
|
|
needs:
|
|
- build
|
|
- npm-package
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
version: ${{ steps.release-metadata.outputs.version }}
|
|
sbom-name: ${{ steps.release-metadata.outputs.sbom-name }}
|
|
steps:
|
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
with:
|
|
bun-version: 1.3.14
|
|
no-cache: true
|
|
|
|
- name: Download binary subjects
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: binaries
|
|
path: release-subjects
|
|
digest-mismatch: error
|
|
|
|
- name: Download npm package subjects
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: npm-packages
|
|
path: release-subjects/npm-packages
|
|
digest-mismatch: error
|
|
|
|
- name: Define release evidence names
|
|
id: release-metadata
|
|
run: |
|
|
version="$(jq -r .version package.json)"
|
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
echo "sbom-name=plannotator-${version}-release-sbom.cdx.json" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Generate SBOM and enforce Grype policy
|
|
env:
|
|
PLANNOTATOR_RELEASE_SUBJECTS_DIR: ${{ github.workspace }}/release-subjects
|
|
PLANNOTATOR_RELEASE_SECURITY_DIR: ${{ runner.temp }}/plannotator-release-security
|
|
run: scripts/release-security/run-release-security.sh
|
|
|
|
- name: Upload public release SBOM
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-sbom
|
|
path: ${{ runner.temp }}/plannotator-release-security/public/*
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
- name: Upload validated subject evidence
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-security-subjects
|
|
path: |
|
|
${{ runner.temp }}/plannotator-release-security/evidence/release-subjects.json
|
|
${{ runner.temp }}/plannotator-release-security/evidence/subject-checksums.sha256
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
- name: Preserve private scanner evidence
|
|
if: ${{ always() }}
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-security-evidence
|
|
path: ${{ runner.temp }}/plannotator-release-security/evidence
|
|
if-no-files-found: warn
|
|
retention-days: 14
|
|
|
|
# Publish only the tarballs produced by the credential-free job. Automatic
|
|
# tag releases are unchanged; a manual publish must also target a v* tag.
|
|
npm-publish:
|
|
needs:
|
|
- release-eligibility
|
|
- npm-package
|
|
- release-security
|
|
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs['dry-run'] == 'false'))
|
|
runs-on: ubuntu-latest
|
|
environment: npm-publish
|
|
permissions:
|
|
contents: read
|
|
id-token: write
|
|
steps:
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: 24
|
|
package-manager-cache: false
|
|
registry-url: https://registry.npmjs.org
|
|
|
|
- name: Download npm package artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: npm-packages
|
|
path: npm-packages
|
|
digest-mismatch: error
|
|
|
|
- name: Publish @plannotator/opencode
|
|
run: npm publish ./npm-packages/plannotator-opencode.tgz --ignore-scripts --provenance --access public
|
|
|
|
- name: Publish @plannotator/pi-extension
|
|
run: npm publish ./npm-packages/plannotator-pi-extension.tgz --ignore-scripts --provenance --access public
|