v1 dogfood and release-candidate runbook
Use this runbook for evidence that automated tests cannot provide: terminal disconnects, native packages, network filesystems, remote notifiers, upgrades, and long-running resource behavior.
Test the exact release-candidate commit and artifacts on disposable Linux, macOS, and Windows accounts. Never use production jobs, credentials, configuration, endpoints, or state.
Release-wide gate
Run once from the release commit:
make check
make snapshot
make e2etest
make perftest
make soaktest SOAK_TIME=10m
All commands must pass. Then run the applicable OS procedures below against the unpacked or installed artifact—not go run.
For every host, record:
- OS version, architecture, filesystem, shell, and local/SSH access;
- release commit, artifact filename, checksum result, and
jobman --version; - operator and UTC start/end times; and
- commands, job IDs, JSON results, and deviations.
Use a fresh local-disk state directory.
Linux and macOS
Linux and macOS share the same CLI and POSIX shell procedure. Platform-specific artifact and host checks are called out where they differ.
1. Start a campaign
In every terminal, use the same CAMPAIGN, STATE_DIR, and JOBMAN_BIN. Replace the first two paths:
cd /path/to/jobman-release-commit
export JOBMAN_BIN=/absolute/path/to/release-candidate/jobman
export CAMPAIGN="$HOME/jobman-dogfood-$(date -u +%Y%m%dT%H%M%SZ)"
export STATE_DIR="$CAMPAIGN/state"
export EVIDENCE_DIR="$CAMPAIGN/evidence"
export CAPTURE="$PWD/devel/dogfood/capture.sh"
export TREE_HELPER="$PWD/devel/dogfood/unix-process-tree.sh"
mkdir -m 700 -p "$STATE_DIR" "$EVIDENCE_DIR"
jm() { "$JOBMAN_BIN" --state-dir "$STATE_DIR" "$@"; }
capture() { "$CAPTURE" "$EVIDENCE_DIR" "$@"; }
printf '%s\n' "CAMPAIGN=$CAMPAIGN" "STATE_DIR=$STATE_DIR" \
"JOBMAN_BIN=$JOBMAN_BIN"
capture LABEL COMMAND ... records arguments, stdout, stderr, UTC times, and exit status. Labels must be unique. Never pass real secrets to capture.
Initial checks:
capture version "$JOBMAN_BIN" --version
capture config "$JOBMAN_BIN" --state-dir "$STATE_DIR" config validate
capture origins "$JOBMAN_BIN" --state-dir "$STATE_DIR" config show --origins
capture doctor "$JOBMAN_BIN" --state-dir "$STATE_DIR" doctor --json
{
uname -a
uname -m
id
df "$STATE_DIR"
} >"$EVIDENCE_DIR/platform.txt"
git rev-parse HEAD >"$EVIDENCE_DIR/release-commit.txt"
On Linux, also record cat /etc/os-release and df -T "$STATE_DIR". On macOS:
sw_vers >>"$EVIDENCE_DIR/platform.txt"
diskutil info "$(df "$STATE_DIR" | awk 'END {print $1}')" \
>>"$EVIDENCE_DIR/platform.txt"
Check:
- version and commit are the intended release candidate;
- configuration is valid;
- schema is supported, store health is good, and foreign-key violations are zero; and
- state permissions are private.
Mount a disposable network filesystem and confirm rejection:
NETWORK_STATE_DIR=/mounted/network/path/jobman-dogfood
if "$JOBMAN_BIN" --state-dir "$NETWORK_STATE_DIR" doctor --json; then
echo 'FAIL: network state directory accepted' >&2
exit 1
fi
2. Verify the artifact
Download the checksum manifest, Sigstore bundle, provenance, and artifact into an empty directory. Replace <version>, OS, and architecture:
cosign verify-blob \
--bundle "jobman_<version>_checksums.txt.sigstore.json" \
--certificate-identity \
'https://github.com/ryancswallace/jobman/.github/workflows/release.yml@refs/heads/main' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"jobman_<version>_checksums.txt"
gh attestation verify --owner ryancswallace \
"jobman_<version>_<os>_<arch>.tar.gz"
slsa-verifier verify-artifact \
--provenance-path jobman.intoto.jsonl \
--source-uri github.com/ryancswallace/jobman \
--source-branch main \
"jobman_<version>_<os>_<arch>.tar.gz"
Linux checksum:
sha256sum --check "jobman_<version>_checksums.txt" --ignore-missing
macOS checksum:
grep "jobman_<version>_darwin_<arch>.tar.gz" \
"jobman_<version>_checksums.txt" |
shasum -a 256 -c
Extract and inspect:
mkdir "$CAMPAIGN/archive"
tar -xzf "jobman_<version>_<os>_<arch>.tar.gz" -C "$CAMPAIGN/archive"
"$CAMPAIGN/archive/jobman" --version
test -s "$CAMPAIGN/archive/docs/manpage/jobman.1"
test -s "$CAMPAIGN/archive/docs/completions/bash/jobman"
test -s "$CAMPAIGN/archive/docs/completions/zsh/_jobman"
"$CAMPAIGN/archive/jobman" config validate \
"$CAMPAIGN/archive/etc/jobman/jobman.yml"
3. Lifecycle and terminal disconnect
In terminal A:
GATE="$CAMPAIGN/disconnect"
rm -f "$GATE"
DISCONNECT_ID=$(jm run -- sh -c '
printf "stdout-line\nstdout-fragment"
printf "stderr-line\nstderr-fragment" >&2
while [ ! -e "$1" ]; do sleep 1; done
' dogfood "$GATE")
printf 'DISCONNECT_ID=%s\n' "$DISCONNECT_ID"
exit
Close terminal A. In terminal B, restore the campaign variables, set the recorded ID, then run:
DISCONNECT_ID=replace-with-job-id
GATE="$CAMPAIGN/disconnect"
jm status "$DISCONNECT_ID"
touch "$GATE"
jm wait "$DISCONNECT_ID"
jm show --json "$DISCONNECT_ID" >"$EVIDENCE_DIR/disconnect.json"
jm logs --stream stdout --raw "$DISCONNECT_ID" >"$CAMPAIGN/actual.stdout"
jm logs --stream stderr --raw "$DISCONNECT_ID" >"$CAMPAIGN/actual.stderr"
printf 'stdout-line\nstdout-fragment' | cmp - "$CAMPAIGN/actual.stdout"
printf 'stderr-line\nstderr-fragment' | cmp - "$CAMPAIGN/actual.stderr"
Repeat through SSH. Kill or disconnect the first SSH client without ending the remote OS user session. Release the gate from an independent SSH connection.
Check:
- the job survives terminal and SSH disconnects;
- no process retains the closed terminal;
- status reaches
success; and - raw output is byte-for-byte exact.
Exercise terminal outcomes:
OK_ID=$(jm run -- sh -c 'exit 0')
FAIL_ID=$(jm run -- sh -c 'exit 23')
CANCEL_ID=$(jm run -- sleep 300)
TIMEOUT_ID=$(jm run --run-timeout 1s -- sleep 300)
START_ID=$(jm run -- /definitely/missing/jobman-command)
FRAGMENT_ID=$(jm run -- sh -c 'printf final-fragment')
jm cancel "$CANCEL_ID"
for id in "$OK_ID" "$FAIL_ID" "$CANCEL_ID" "$TIMEOUT_ID" \
"$START_ID" "$FRAGMENT_ID"; do
jm wait "$id" || :
jm show --json "$id" >"$EVIDENCE_DIR/lifecycle-$id.json"
done
test "$(jm logs --stream stdout --raw "$FRAGMENT_ID")" = final-fragment
Check each documented outcome, reason, exit fact, and final fragment.
4. Process-tree control
Graceful cancellation, pause, and resume:
TREE_CASE="$CAMPAIGN/tree-graceful"
mkdir "$TREE_CASE"
TREE_ID=$(jm run --stop-grace 5s -- \
"$TREE_HELPER" graceful "$TREE_CASE/pids" "$TREE_CASE/progress")
for attempt in 1 2 3 4 5; do
test -f "$TREE_CASE/pids" &&
test "$(wc -l <"$TREE_CASE/pids")" -eq 3 && break
sleep 1
done
if ! test -f "$TREE_CASE/pids" ||
! test "$(wc -l <"$TREE_CASE/pids")" -eq 3; then
cat "$TREE_CASE/pids" 2>/dev/null || :
jm cancel "$TREE_ID"
jm wait "$TREE_ID"
echo 'FAIL: helper did not record three processes' >&2
exit 1
fi
jm pause "$TREE_ID"
sleep 2
PAUSED_LINES=$(wc -l <"$TREE_CASE/progress")
sleep 3
test "$(wc -l <"$TREE_CASE/progress")" -eq "$PAUSED_LINES"
jm resume "$TREE_ID"
sleep 3
test "$(wc -l <"$TREE_CASE/progress")" -gt "$PAUSED_LINES"
jm cancel "$TREE_ID"
jm wait "$TREE_ID"
while read -r role pid; do
! kill -0 "$pid" 2>/dev/null ||
{ echo "FAIL: $role PID $pid survived" >&2; exit 1; }
done <"$TREE_CASE/pids"
Forced escalation and cancellation while paused:
for mode in stubborn cancel-paused; do
TREE_CASE="$CAMPAIGN/tree-$mode"
mkdir "$TREE_CASE"
TREE_ID=$(jm run --stop-grace 2s -- \
"$TREE_HELPER" stubborn "$TREE_CASE/pids" "$TREE_CASE/progress")
sleep 3
test "$mode" != cancel-paused || jm pause "$TREE_ID"
jm cancel "$TREE_ID"
jm wait "$TREE_ID"
while read -r role pid; do
! kill -0 "$pid" 2>/dev/null ||
{ echo "FAIL: $role PID $pid survived" >&2; exit 1; }
done <"$TREE_CASE/pids"
jm show --json "$TREE_ID" >"$EVIDENCE_DIR/tree-$mode.json"
done
Check the parent, child, and grandchild all stop. Use recorded process identity, not PID alone, to rule out signalling a reused PID.
5. Scheduling, dependencies, and concurrency
Run this minimum matrix:
- retryable exit, start failure, timeout, and nonretryable exit;
- constant, linear, and exponential backoff; maximum delay and jitter bounds;
- run, success, and failure limits;
- every dependency predicate, multiple prerequisites, and an impossible dependency;
- time, delay, file, and executable waits, including abort deadlines;
- global and named-pool limits, multi-slot jobs, FIFO/bounded bypass;
- pause/resume while waiting, queued, and backing off; and
config applyplus removal of an unused pool.
Core recipes:
RETRY_ID=$(jm run --retries 3 --retryable-exit-code 17 \
--retry-delay 250ms --retry-backoff exponential --retry-max-delay 1s -- \
sh -c 'exit 17')
TIMEOUT_RETRY_ID=$(jm run --retries 1 --retry-timeouts \
--run-timeout 250ms --retry-delay 100ms -- sleep 5)
START_RETRY_ID=$(jm run --retries 1 --retry-start-failures \
--retry-delay 100ms -- /definitely/missing/jobman-command)
for id in "$RETRY_ID" "$TIMEOUT_RETRY_ID" "$START_RETRY_ID"; do
jm wait "$id" || :
jm show --json "$id" >"$EVIDENCE_DIR/retry-$id.json"
done
SUCCESS_ID=$(jm run -- sh -c 'exit 0')
FAILED_ID=$(jm run -- sh -c 'exit 9')
jm wait "$SUCCESS_ID"
jm wait "$FAILED_ID" || :
for predicate in success failed finish; do
id=$(jm run "--after-$predicate" \
"$(test "$predicate" = success && echo "$SUCCESS_ID" || echo "$FAILED_ID")" \
-- sh -c 'exit 0')
jm wait "$id"
done
IMPOSSIBLE_ID=$(jm run --after-success "$FAILED_ID" -- sh -c 'exit 0')
jm wait "$IMPOSSIBLE_ID" || :
jm show --json "$IMPOSSIBLE_ID" >"$EVIDENCE_DIR/dependency-impossible.json"
Named-pool admission:
POLICY_CONFIG="$CAMPAIGN/policy.yml"
cat >"$POLICY_CONFIG" <<'YAML'
schema_version: 1
concurrency:
max_active_slots: 3
pools:
dogfood: 2
YAML
"$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$POLICY_CONFIG" config apply
POOL_A=$("$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$POLICY_CONFIG" \
run --pool dogfood --slots 2 -- sleep 10)
POOL_B=$("$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$POLICY_CONFIG" \
run --pool dogfood --slots 1 -- sleep 10)
sleep 2
jm show --json "$POOL_A" >"$EVIDENCE_DIR/pool-active.json"
jm show --json "$POOL_B" >"$EVIDENCE_DIR/pool-queued.json"
jm cancel "$POOL_A"
jm wait "$POOL_B"
Concurrent readers and cancellation:
RACE_ID=$(jm run -- sleep 300)
sleep 2
jm list --json >"$EVIDENCE_DIR/concurrent-list.json" & p1=$!
jm show --json "$RACE_ID" >"$EVIDENCE_DIR/concurrent-show.json" & p2=$!
jm doctor --json >"$EVIDENCE_DIR/concurrent-doctor.json" & p3=$!
jm cancel "$RACE_ID" >"$EVIDENCE_DIR/concurrent-cancel.txt" & p4=$!
wait "$p1" "$p2" "$p3" "$p4"
jq -e . "$EVIDENCE_DIR"/concurrent-*.json >/dev/null
Any invalid JSON, impossible transition, database-busy loop, or reader-induced mutation blocks release.
6. Logs, live input, configuration, and redaction
Binary logs and rotation:
dd if=/dev/urandom of="$CAMPAIGN/expected.bin" bs=1024 count=16
LOG_ID=$(jm run --log-segment-bytes 1024 --log-segments 32 -- \
cat "$CAMPAIGN/expected.bin")
jm wait "$LOG_ID"
jm logs --stream stdout --raw "$LOG_ID" >"$CAMPAIGN/actual.bin"
cmp "$CAMPAIGN/expected.bin" "$CAMPAIGN/actual.bin"
FOLLOW_ID=$(jm run -- sh -c \
'for i in 1 2 3 4 5; do echo "line-$i"; sleep 1; done')
jm logs --follow --stream stdout --raw "$FOLLOW_ID" \
>"$EVIDENCE_DIR/follow.txt"
test "$(wc -l <"$EVIDENCE_DIR/follow.txt")" -eq 5
Live input from two clients:
dd if=/dev/urandom of="$CAMPAIGN/a.bin" bs=4096 count=8
dd if=/dev/urandom of="$CAMPAIGN/b.bin" bs=4096 count=8
INPUT_ID=$(jm run --stdin live -- cat)
jm input "$INPUT_ID" <"$CAMPAIGN/a.bin" & a=$!
jm input "$INPUT_ID" <"$CAMPAIGN/b.bin" & b=$!
wait "$a" "$b"
find "$STATE_DIR" -type s -ls >"$EVIDENCE_DIR/input-sockets.txt" || :
jm input --eof "$INPUT_ID"
! jm input --eof "$INPUT_ID"
jm wait "$INPUT_ID"
jm logs --stream stdout --raw "$INPUT_ID" >"$CAMPAIGN/input.bin"
cat "$CAMPAIGN/a.bin" "$CAMPAIGN/b.bin" >"$CAMPAIGN/ab.bin"
cat "$CAMPAIGN/b.bin" "$CAMPAIGN/a.bin" >"$CAMPAIGN/ba.bin"
cmp "$CAMPAIGN/input.bin" "$CAMPAIGN/ab.bin" ||
cmp "$CAMPAIGN/input.bin" "$CAMPAIGN/ba.bin"
Check socket ownership and mode; group/other access must be absent. Confirm no TCP listener appears. Rerun the job and verify it receives a fresh endpoint.
Malformed configuration must fail validation without disabling emergency commands:
cat >"$CAMPAIGN/bad.yml" <<'YAML'
schema_version: 1
unknown_v1_key: reject-me
YAML
! "$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$CAMPAIGN/bad.yml" \
config validate
for command in list doctor; do
"$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$CAMPAIGN/bad.yml" "$command"
done
for command in status show logs; do
"$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$CAMPAIGN/bad.yml" \
"$command" "$OK_ID"
done
EMERGENCY_ID=$(jm run -- sleep 300)
"$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$CAMPAIGN/bad.yml" \
cancel "$EMERGENCY_ID"
Also verify:
- CLI, environment, explicit file, user file, and defaults have documented precedence and origins;
- trusted-project restrictions and unknown-key rejection;
config applyaffectsrun,rerun, and policy cleanup; andclean --allandclean --older-thando not gain configuration authority.
Use unique fake values for token, password, and a redaction pattern. Confirm they never occur in diagnostics, JSON, notifier diagnostics, or errors:
export JOBMAN_DOGFOOD_TOKEN=dogfood-token-7f8f6018
export JOBMAN_DOGFOOD_PASSWORD=dogfood-password-c24ac7b1
export JOBMAN_DOGFOOD_PATTERN=private-8675309
cat >"$CAMPAIGN/redaction.yml" <<'YAML'
schema_version: 1
secrets:
token: env:JOBMAN_DOGFOOD_TOKEN
password: env:JOBMAN_DOGFOOD_PASSWORD
redaction:
names: [token, password]
patterns: ['private-[0-9]+']
YAML
REDACT_ID=$("$JOBMAN_BIN" --state-dir "$STATE_DIR" \
--config "$CAMPAIGN/redaction.yml" run \
--name "$JOBMAN_DOGFOOD_PATTERN" \
--secret-env TOKEN=token --secret-env PASSWORD=password -- \
printf %s "$JOBMAN_DOGFOOD_PATTERN")
jm wait "$REDACT_ID"
"$JOBMAN_BIN" --state-dir "$STATE_DIR" --config "$CAMPAIGN/redaction.yml" \
show --json "$REDACT_ID" >"$EVIDENCE_DIR/redaction.json"
grep -F '[REDACTED]' "$EVIDENCE_DIR/redaction.json" >/dev/null
! grep -R -F -e "$JOBMAN_DOGFOOD_TOKEN" \
-e "$JOBMAN_DOGFOOD_PASSWORD" -e "$JOBMAN_DOGFOOD_PATTERN" "$EVIDENCE_DIR"
test "$(jm logs --stream stdout --raw "$REDACT_ID")" = \
"$JOBMAN_DOGFOOD_PATTERN"
Raw target logs intentionally remain outside diagnostic redaction.
7. Notifications and crash recovery
Use the checked-in command notifier first:
NOTIFIER="$PWD/devel/dogfood/command-notifier.sh"
EVENTS="$CAMPAIGN/notifier-events"
mkdir "$EVENTS"
cat >"$CAMPAIGN/notifiers.yml" <<YAML
schema_version: 1
notifiers:
audit:
type: command
events: [job_succeeded]
timeout: 5s
retry: {max_attempts: 1, delay: 10ms, max_delay: 10ms}
command:
command: ["$NOTIFIER", success, "$EVENTS/audit"]
output_limit: 4KiB
failing:
type: command
events: [job_succeeded]
timeout: 1s
retry: {max_attempts: 2, delay: 100ms, max_delay: 100ms}
command:
command: ["$NOTIFIER", failure, "$EVENTS/failing"]
output_limit: 32B
YAML
for notifier in audit failing; do
id=$("$JOBMAN_BIN" --state-dir "$STATE_DIR" \
--config "$CAMPAIGN/notifiers.yml" run --notify "$notifier" -- \
sh -c 'exit 0')
jm wait "$id"
sleep 3
jm show --json "$id" >"$EVIDENCE_DIR/notifier-$notifier.json"
done
for payload in "$EVENTS"/*/*.event.json; do jq -e . "$payload" >/dev/null; done
Check one successful attempt, two bounded failures, 32-byte diagnostic truncation, valid v1 payloads, unique event IDs, and unchanged job outcomes.
Then test recovery:
- Configure
command-notifier.sh slow-oncewith a five-minute timeout. - Submit a successful job.
- Wait for its
.claimedmarker. - Kill the recorded supervisor and notifier PIDs.
- Wait longer than the 15-second lease.
- Run
jm doctor --repair. - Confirm one durable completed attempt and stable idempotency key.
Repeat success, timeout, retry, and non-success response cases against operator-controlled SMTP and HTTPS capture services. Record sanitized headers, timestamps, statuses, truncation, and attempt counts. Never use production credentials or endpoints.
8. Cleanup, backup, and upgrade
Cleanup must default to dry-run:
CLEAN_ID=$(jm run -- sh -c 'printf retained-log')
jm wait "$CLEAN_ID"
jm clean "$CLEAN_ID" --older-than 0s >"$EVIDENCE_DIR/clean-dry-run.txt"
test "$(jm logs --stream stdout --raw "$CLEAN_ID")" = retained-log
jm clean "$CLEAN_ID" --older-than 0s --dry-run=false --force \
>"$EVIDENCE_DIR/clean-forced.txt"
! jm logs --stream stdout --raw "$CLEAN_ID"
jm show --json "$CLEAN_ID" >"$EVIDENCE_DIR/clean-tombstone.json"
Also confirm active admissions, unresolved dependencies, and pending notifications block deletion. Verify log age, log tombstones, and metadata retention.
Create a backup:
BACKUP_DB="$CAMPAIGN/pre-upgrade.db"
jm doctor --backup "$BACKUP_DB"
chmod 600 "$BACKUP_DB"
if command -v sha256sum >/dev/null; then
sha256sum "$BACKUP_DB" >"$EVIDENCE_DIR/pre-upgrade.sha256"
else
shasum -a 256 "$BACKUP_DB" >"$EVIDENCE_DIR/pre-upgrade.sha256"
fi
For every supported prior schema fixture and the latest released binary:
- Copy it to a fresh state directory.
- Record the old binary’s
doctor --json. - Create representative jobs, logs, dependencies, and notifications.
- Open the state with the release candidate.
- Confirm a pre-migration backup, healthy store, matching counts, and intact logs.
- Restore into a third directory using UPGRADING.md.
- Make
backups/unwritable on another copy; confirm migration fails and the database digest is unchanged.
Never restore over the original state.
9. Native installation and endurance
Test only packages native to the disposable host.
Linux:
sudo apt install ./jobman_<version>_linux_<arch>.deb
# or: sudo dnf install ./jobman_<version>_linux_<arch>.rpm
# or: sudo apk add --allow-untrusted ./jobman_<version>_linux_<arch>.apk
jobman --version
jobman config validate /etc/jobman/jobman.yml
man jobman
Also install through the public Cloudsmith repository on one DNF-compatible host:
curl -1sLf \
'https://dl.cloudsmith.io/public/jobman/stable/cfg/setup/bash.rpm.sh' |
sudo -E bash
sudo dnf install jobman
On Fedora 44 or later, Cloudsmith’s generated repository must not force the retired /etc/pki/tls/certs/ca-bundle.crt path. Treat that generator defect as an external publishing blocker; do not disable TLS verification.
macOS:
brew install ryancswallace/tap/jobman
jobman --version
jobman config validate "$(brew --prefix)/etc/jobman/jobman.yml"
man jobman
For every package:
- start a new Bash and Zsh session;
jobman ru<Tab>completes tojobman run; - verify man pages, sample configuration, and private writable user state;
- upgrade, uninstall, and reinstall with the native package manager;
- confirm uninstall preserves user state and follows configuration-preservation rules; and
- on macOS, record any Gatekeeper behavior. Never bypass quarantine as an acceptance step.
Verify the signed container on Linux:
DOCKER_CONFIG="$CAMPAIGN/docker-config" docker pull \
"ghcr.io/ryancswallace/jobman:<version>"
cosign verify \
--certificate-identity \
'https://github.com/ryancswallace/jobman/.github/workflows/release.yml@refs/heads/main' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"ghcr.io/ryancswallace/jobman:<version>"
docker run --rm --init "ghcr.io/ryancswallace/jobman:<version>" --version
Check anonymous pull, unprivileged identity, persistent state, signal handling, and a derived image containing a target command.
Run a 24-hour soak on each OS:
JOBMAN_BIN="$JOBMAN_BIN" ./devel/dogfood/soak.sh \
"$CAMPAIGN/soak-state" "$EVIDENCE_DIR/soak" 86400
Sample CPU, memory, process tree, file descriptors, database/WAL/log sizes, and command latency at fixed intervals. Exercise retries, timeouts, rotation, live input, notifications, cleanup dry-runs, and doctor. Unbounded growth, leaked processes, or unexplained errors block release.
Windows
Run this procedure in native PowerShell—not WSL, MSYS2, or a Linux container.
1. Start a campaign
Set-Location C:\path\to\jobman-release-commit
$JobmanBin = 'C:\absolute\path\to\jobman.exe'
$Campaign = Join-Path $HOME (
'jobman-dogfood-' + (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
)
$StateDir = Join-Path $Campaign 'state'
$EvidenceDir = Join-Path $Campaign 'evidence'
New-Item -ItemType Directory -Force $StateDir, $EvidenceDir | Out-Null
function jm { & $JobmanBin --state-dir $StateDir @args }
Write-Host "Campaign=$Campaign"
Write-Host "StateDir=$StateDir"
Initial checks:
& $JobmanBin --version |
Tee-Object (Join-Path $EvidenceDir 'version.txt')
jm config validate |
Tee-Object (Join-Path $EvidenceDir 'config.txt')
jm config show --origins |
Tee-Object (Join-Path $EvidenceDir 'origins.json')
jm doctor --json |
Tee-Object (Join-Path $EvidenceDir 'doctor.json')
Get-ComputerInfo | Out-File (Join-Path $EvidenceDir 'platform.txt')
Get-Volume | Out-File (Join-Path $EvidenceDir 'volumes.txt')
git rev-parse HEAD | Out-File (Join-Path $EvidenceDir 'release-commit.txt')
Check version, schema, health, foreign keys, ACLs, and local filesystem. Map a disposable network share and confirm doctor --json rejects it.
2. Verify the ZIP
Verify the checksum manifest, Sigstore bundle, attestation, and provenance as in the Unix artifact procedure, using jobman_<version>_windows_<arch>.zip. Then:
$Archive = 'C:\path\to\jobman_<version>_windows_<arch>.zip'
$Extracted = Join-Path $Campaign 'archive'
Expand-Archive $Archive $Extracted
& (Join-Path $Extracted 'jobman.exe') --version
& (Join-Path $Extracted 'jobman.exe') config validate `
(Join-Path $Extracted 'etc\jobman\jobman.yml')
Test-Path (Join-Path $Extracted 'docs\completions\powershell\jobman.ps1')
Dot-source the packaged completion in a disposable PowerShell process and test completion. Confirm extraction creates no machine-wide state; Windows releases are portable ZIPs, not MSI installers.
3. Lifecycle and terminal disconnect
In PowerShell window A:
$Gate = Join-Path $Campaign 'disconnect'
$Id = jm run --wait-file $Gate -- powershell.exe -NoProfile -Command `
'[Console]::Out.Write("windows-stdout"); [Console]::Error.Write("windows-stderr")'
Write-Host "DISCONNECT_ID=$Id"
Close window A. In window B, restore the same campaign values:
$Id = 'replace-with-job-id'
$Gate = Join-Path $Campaign 'disconnect'
jm status $Id
New-Item -ItemType File -Force $Gate | Out-Null
jm wait $Id
jm show --json $Id |
Tee-Object (Join-Path $EvidenceDir 'disconnect.json')
jm logs --stream stdout --raw $Id
jm logs --stream stderr --raw $Id
Repeat through native OpenSSH using two independent clients. Check exact stdout/stderr, successful completion, and no retained terminal handles.
Also run and inspect:
- exit 0 and nonzero;
- cancellation;
- run timeout;
- missing executable; and
- output without a trailing newline.
4. Process-tree control
$TreeId = jm run --stop-grace 2s -- powershell.exe -NoProfile -Command `
'$p = Start-Process powershell.exe -ArgumentList "-NoProfile","-Command","Start-Sleep 300" -PassThru; Write-Output $PID; Write-Output $p.Id; Wait-Process $p.Id'
Start-Sleep 2
$PidFile = Join-Path $EvidenceDir 'tree-pids.txt'
jm logs --stream stdout --raw $TreeId | Tee-Object $PidFile
jm pause $TreeId
Start-Sleep 3
jm resume $TreeId
jm cancel $TreeId
jm wait $TreeId
Get-Content $PidFile | ForEach-Object {
if (Get-Process -Id ([int]$_) -ErrorAction SilentlyContinue) {
throw "PID $_ survived cancellation"
}
}
Repeat while paused and with a descendant that ignores graceful termination. Using Process Explorer or an approved equivalent, record Job Object membership and handle counts. Best-effort console break may fail; forced Job Object termination must stop the entire tree after the grace period.
5. Functional matrix
Run the same behavioral matrix as Linux/macOS:
- retry and backoff modes, limits, dependency predicates, waits, and aborts;
- global/named-pool admission, multiple slots, FIFO/bounded bypass;
- pause/resume in waiting, queued, running, and backoff states;
- concurrent
list,show,logs,doctor, andcancel; - binary/rotated/interleaved logs and follow-to-completion;
- multi-client binary live input, EOF, repeated EOF, and rerun;
- configuration precedence, strict validation, emergency commands, and
config apply; - redaction canaries;
- command, SMTP, and HTTPS notifiers, including retry and crash recovery;
- cleanup dry-run/force, retention blockers, backup, restore, and upgrade.
Use native equivalents:
Start-Sleepinstead ofsleep;Get-Content -Raw FILE | ConvertFrom-Jsonto validate JSON;Get-FileHash -Algorithm SHA256for digests;Compare-Objector byte arrays for exact comparisons;- named-pipe ACL inspection instead of Unix socket modes; and
Get-NetTCPConnection -State Listento prove live input opens no TCP port.
Every final job must have the documented state, reason, attempts, and exit facts. Invalid JSON, impossible transitions, database-busy loops, leaked handles, or reader-induced mutations block release.
6. Endurance
Run the native 24-hour driver:
.\devel\dogfood\soak.ps1 -JobmanBin $JobmanBin `
-StateDir (Join-Path $Campaign 'soak-state') `
-EvidenceDir (Join-Path $EvidenceDir 'soak') `
-DurationSeconds 86400
At fixed intervals record CPU, memory, process tree, handles, database/WAL/log sizes, and command latency. Unbounded growth, leaked processes/handles, or unexplained errors block release.
Final evidence and release decision
On Linux/macOS:
capture doctor-final "$JOBMAN_BIN" --state-dir "$STATE_DIR" doctor --json
find "$EVIDENCE_DIR" -type f -print | sort >"$EVIDENCE_DIR/manifest.txt"
grep -R -n -E '(token|password|authorization|secret)' "$EVIDENCE_DIR" \
>"$CAMPAIGN/evidence-secret-review.txt" || :
tar -czf "$CAMPAIGN/evidence-review-required.tar.gz" \
-C "$CAMPAIGN" evidence
On Windows:
jm doctor --json | Out-File (Join-Path $EvidenceDir 'doctor-final.json')
Get-ChildItem -Recurse -File $EvidenceDir |
Select-Object -ExpandProperty FullName |
Out-File (Join-Path $EvidenceDir 'manifest.txt')
Get-ChildItem -Recurse -File $EvidenceDir |
Select-String -Pattern 'token|password|authorization|secret' |
Out-File (Join-Path $Campaign 'evidence-secret-review.txt')
Compress-Archive $EvidenceDir `
(Join-Path $Campaign 'evidence-review-required.zip')
The secret scan is a human-review queue, not proof that evidence is safe. Do not archive the state directory by default; job specifications and raw logs can contain target data.
Release only when:
- every required automated and manual check passed on all supported OSes;
- artifact identity, signatures, provenance, and native installation passed;
- upgrades preserve state and failed migrations preserve the original;
- no process, handle, descriptor, database, or storage leak remains;
- notification failure never changes the job outcome;
- all deviations have resolved issue links; and
- an operator has reviewed the evidence for secrets.