Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
17 changes: 10 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
9 changes: 8 additions & 1 deletion bin/resenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ async function main (): Promise<void> {
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
const handlers = new Map<NodeJS.Signals, () => 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)
}
Expand Down
10 changes: 6 additions & 4 deletions docs/maintainer-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions test/package-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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-'))
Expand Down
16 changes: 11 additions & 5 deletions test/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,26 +113,32 @@ 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 = ''
child.once('error', reject)
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)
}
})
Expand Down
Loading