diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 654e0bf..76ae22b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,8 @@ jobs: file: 'cobertura-coverage.xml' language: 'TypeScript' label: 'code-coverage/vitest' + # The first PR report processed after the default 160-second wait. + wait-for-processing-timeout: '240' release: needs: [build, test, coverage] diff --git a/AGENTS.md b/AGENTS.md index 5c7d8fd..2609de2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,12 @@ Node.js package: `@ciqol/resenv`, MIT licensed, copyright ciqol contributors. Start each session with [the maintainer handoff](docs/maintainer-handoff.md), then inspect Git, GitHub and npm for current state; this chat is not required. -The user approved public source and the transition to `beta` as the sole remote -and default branch. PR creation remains on hold. Keep the development root and -ordinary local builds private. CI generates the release version before packing, +The user approved public source and `beta` as the default and sole release +branch. Open PRs only when asked, using temporary `titan/*` remote branches. +Keep the development root and ordinary local builds private. CI generates the +release version before packing, using semantic-release's official commit-analysis and release-notes plugins. -The sole branch is `beta`; npm tags `beta` and `latest` point to the same tested +The release branch is `beta`; npm tags `beta` and `latest` point to the same tested beta release. The first version is `0.1.0-beta.1`; real beta release tags supply subsequent counters. The full semantic-release engine is not used while there is no stable release branch. @@ -117,11 +118,13 @@ that. - Commit on a `titan/*` branch, never `beta`, `main`, `master`, or the default branch. Use Conventional Commits and preserve signing. Add no co-author trailers or attribution footers. -- Keep `beta` as the sole remote branch. Once a pending release is recorded or - explicitly rejected for replacement, completed commits from the local task +- Keep `beta` as the default and sole release branch. Temporary `titan/*` remote + branches are allowed for requested PRs; leave `beta` unchanged for PR review. + Once a pending release is recorded or explicitly rejected for replacement, + completed commits from the local task branch may fast-forward `beta` when the user has authorized delivery. Do not force-push the default branch. -- PR creation is on hold. Do not merge, enable auto-merge, change visibility, +- Open PRs only when asked. Do not merge, enable auto-merge, change visibility, collaborators, permissions or protections without explicit authorization. - Keep credentials, dotenv files, local consumer links and employer-specific names or URLs out of commits, issues and release notes. diff --git a/bin/resenv.ts b/bin/resenv.ts index 4da47d0..06632a6 100644 --- a/bin/resenv.ts +++ b/bin/resenv.ts @@ -60,7 +60,14 @@ async function main (): Promise { const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'] const handlers = new Map void>() for (const signal of signals) { - const handler = () => { child.kill(signal) } + const handler = () => { + try { + if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal) + else child.kill(signal) + } catch (err) { + if (!hasErrorCode(err, 'ESRCH')) throw err + } + } handlers.set(signal, handler) process.on(signal, handler) } diff --git a/docs/maintainer-handoff.md b/docs/maintainer-handoff.md index bae01f6..78d2cb3 100644 --- a/docs/maintainer-handoff.md +++ b/docs/maintainer-handoff.md @@ -10,9 +10,10 @@ the push remote and recent commits before changing anything. The repository is [ciqol/resenv](https://github.com/ciqol/resenv). Authenticate GitHub as `titan-ron` using the repository-local credential setup in AGENTS.md. -`beta` is the sole remote and default branch. Work on a local `titan/*` branch; -never commit directly on the default branch. The repository stores all source, -tests, build configuration and operating instructions needed to continue. +`beta` is the default and sole release branch. Work on a `titan/*` branch; +when a PR is requested, push that temporary branch and leave `beta` unchanged +for review. Never commit directly on the default branch. The repository stores +all source, tests, build configuration and operating instructions needed to continue. Do not depend on previous chat history or copy credentials between sessions. ## Product contract @@ -102,7 +103,8 @@ would remove the package and impose npm's publishing cooldown. ## Known limits and follow-ups - Real Doppler challenge generation, pending polling and timeout were checked; - browser approval and issued-token recovery still require a personal/test account. + the maintainer skipped live account approval, so issued-token recovery remains + unverified. macOS storage passed in an isolated temporary keychain. CI checks Linux storage through real Secret Service in a disposable session with dummy credentials; Windows native login/storage is not implemented. Explicit tokens and existing diff --git a/docs/validation.md b/docs/validation.md index 210ce3b..44338a0 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,5 +1,23 @@ # Validation +## October 13, 2026 — PR review and command shutdown + +Review found that forwarding a signal only to the wrapped command could leave +its worker running and hang shutdown. The expanded regression timed out before +the fix. POSIX forwarding now targets the command's isolated process group; +SIGINT, SIGTERM and SIGHUP each reach both processes once, and graceful shutdown +completes. Already-exited groups are tolerated; Windows keeps direct signaling. +Pinned Node 24 verification passed lint, types, build, 101 unit/tooling tests, +41 process tests, 16 packed runtime checks and two typed consumers. Runtime +audit: zero findings; the 12 bounded development exceptions are unchanged. +The first PR run also exposed a candidate check against GitHub's synthetic merge +SHA despite checking out the PR head. Package verification now uses the actual +checkout commit. The exact hosted archive passed with a simulated merge SHA; +a candidate with the wrong source commit still failed. +GitHub posted the first PR coverage report after the action's 160-second wait +expired. The official uploader now allows 240 seconds within the five-minute +job limit, retaining failure on upload or processing errors. + ## October 13, 2026 — native boundaries and official coverage action Live Doppler generation rejected `v0.1.0-beta.1` with HTTP 400 but accepted diff --git a/test/package-smoke.mjs b/test/package-smoke.mjs index 0c1dbf7..6053d17 100644 --- a/test/package-smoke.mjs +++ b/test/package-smoke.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' @@ -10,9 +10,9 @@ import { verifyArtifact } from '../scripts/release-artifact.mjs' assert.ok(process.argv.length <= 4, 'Usage: npm run test:package -- [package.tgz [release.json]]') const suppliedArchive = process.argv[2] ? resolve(process.argv[2]) : undefined -const plan = process.argv[3] ? verifyArtifact(dirname(resolve(process.argv[3])), { head: process.env.GITHUB_SHA }).plan : undefined -if (plan) assert.equal(suppliedArchive, resolve(dirname(process.argv[3]), plan.archive), 'Test the archive described by this release plan') const root = fileURLToPath(new URL('..', import.meta.url)) +const plan = process.argv[3] ? verifyArtifact(dirname(resolve(process.argv[3])), { head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 10000 }).trim() }).plan : undefined +if (plan) assert.equal(suppliedArchive, resolve(dirname(process.argv[3]), plan.archive), 'Test the archive described by this release plan') const env = { PATH: `${dirname(process.execPath)}:${process.env.PATH}` } const source = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) const directory = mkdtempSync(join(tmpdir(), 'resenv-package-')) diff --git a/test/runtime.test.mjs b/test/runtime.test.mjs index f1fb559..e6730d3 100644 --- a/test/runtime.test.mjs +++ b/test/runtime.test.mjs @@ -113,17 +113,22 @@ test('wrapper forwards SIGTERM and preserves the child termination signal', { sk applicationPid = undefined }) -test('wrapper delivers process-group signals once and waits for graceful shutdown', { skip: process.platform === 'win32', timeout: 10000 }, async t => { +test('wrapper delivers process-group signals to the command and its worker once and waits for graceful shutdown', { skip: process.platform === 'win32', timeout: 10000 }, async t => { const context = fixture(t) for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { - const script = `let count=0; process.on('${signal}',()=>{ if(++count===1) setTimeout(()=>process.exit(count===1?0:9),100) }); console.log(process.pid); setInterval(()=>{},1000)` + const worker = `let count=0; process.on('${signal}',()=>{ if(++count===1) setTimeout(()=>process.exit(count===1?0:9),100) }); console.log('worker '+process.pid); setInterval(()=>{},1000)` + const script = `const child=require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(worker)}],{stdio:'inherit'}); let count=0; process.on('${signal}',()=>count++); child.once('exit',code=>process.exit(code===0&&count===1?0:9)); console.log('command '+process.pid); setInterval(()=>{},1000)` const child = spawn(process.execPath, [cli, 'run', '--', process.execPath, '-e', script], { cwd: context.cwd, env: context.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }) let applicationPid t.after(() => { child.kill('SIGKILL') - if (applicationPid) { try { process.kill(applicationPid, 'SIGKILL') } catch {} } + if (applicationPid) { + try { process.kill(-applicationPid, 'SIGKILL') } catch (err) { + if (err.code !== 'ESRCH') throw err + } + } }) const result = await new Promise((resolve, reject) => { let ready = '' @@ -131,8 +136,9 @@ test('wrapper delivers process-group signals once and waits for graceful shutdow child.once('exit', (code, signal) => resolve({ code, signal })) child.stdout.on('data', data => { ready += data.toString() - if (ready.includes('\n') && !applicationPid) { - applicationPid = Number(ready.trim()) + const commandPid = ready.match(/^command (\d+)$/m)?.[1] + if (commandPid && /^worker \d+$/m.test(ready) && !applicationPid) { + applicationPid = Number(commandPid) process.kill(-child.pid, signal) } })