Offline Kit — baseline assembly runbook

Audience: Scanner operators, DevOps/release engineers, and Offline Kit maintainers.

Companion doc: offline-kit-rotation.md(when to rotate, key custody, publication, and consumer verification on top of this assembler contract).

This runbook covers the baseline Offline Kit assembly pipeline introduced in Sprint 20260513_011. It is the lightweight, in-tree slice that bundles the air-gap-relevant artefacts that ship inside the repo:

It is not a replacement for the full release packager at devops/offline/kit/build_offline_kit.py, which handles SBOMs, DSSE envelopes, debug stores, and telemetry bundles. The baseline pipeline exists so an operator can quickly produce a deterministic air-gap tarball without first running the release orchestrator.


1. When to use this pipeline

GoalUse
Cut a hash-verifiable in-tree air-gap bundle for the secrets-bundle rotation flowBaseline (this doc)
Build the full release Offline Kit (SBOMs, DSSE, telemetry, debug-store)devops/offline/kit/build_offline_kit.py
Smoke-test the air-gap manifest contract in CIBaseline (this doc) + Tests/assemble-determinism-test.sh

2. Prerequisites

ToolPOSIXWindows
tarGNU tar 1.30+tar.exe bundled in Windows 10+ (bsdtar)
gzipanyn/a — PS variant uses an inlined deterministic gzip writer
sha256sum or shasumeithern/a — PS uses Get-FileHash
Shellbash (or POSIX sh)PowerShell 5.1+

No new third-party dependencies; no network access required.


3. Invocation

3.1 POSIX / macOS / Git-Bash

./devops/scripts/offline-kit/assemble.sh \
    --version 2026.05.13 \
    --out out/offline-kit-baseline

Flags:

FlagRequiredDefaultNotes
--version <ver>yesEmbedded in manifest + filenames; [A-Za-z0-9._-] only.
--out <dir>no<repo>/out/offline-kit-baselineCreated if absent.
--source-root <dir>norepo root (detected)Useful when scanning a checkout other than the current one.
--manifest-onlynooffEmit only the JSON manifest; skip the tarball.
--cinooffCI mode (Sprint 018): emits Gitea Actions annotations on stderr and writes offline-kit-baseline-<version>.summary.json next to the outputs. Additive; non-CI invocations are unchanged.
--helpnoShow usage.

3.2 Windows / PowerShell

powershell -ExecutionPolicy Bypass `
    -File .\devops\scripts\offline-kit\assemble.ps1 `
    -Version 2026.05.13 `
    -OutDir out\offline-kit-baseline

Parameters mirror the bash flags. The PowerShell script delegates the tar step to tar.exe (bsdtar, ships with Windows 10+) and writes the gzip payload through System.IO.Compression.DeflateStream with a fixed header so the output is deterministic within the PowerShell-on-Windows host.


4. Output

Two files land in --out (default out/offline-kit-baseline/):

offline-kit-baseline-<version>.manifest.json
offline-kit-baseline-<version>.tar.gz

4.1 Manifest shape

{
  "schemaVersion": "1.0",
  "kind": "stellaops.offline-kit.baseline",
  "version": "2026.05.13",
  "generatedAt": "1970-01-01T00:00:00Z",
  "sourceDateEpoch": 0,
  "files": [
    {
      "path": "datasets/trust-store/scanner-plugins/dev/index.json",
      "sha256": "abc123…",
      "sizeBytes": 1234
    }
  ]
}

The files[] array is sorted lexically by path using ordinal (byte-value) comparison so the order matches POSIX LC_ALL=C sort exactly. path is the in-archive path, never a host-absolute path.

4.2 Archive shape

0-manifest.json            <-- always the first entry
datasets/...               <-- everything from src/__Tests/__Datasets/{dart,elixir,os,swift,synthetic-repos-seed,trust-store}/
plugins/scanner/analyzers/<name>/manifest.json

0-manifest.json is named so it sorts before any other top-level entry, so a consumer can stream the first record and decide what to extract.


5. Determinism guarantees

The baseline pipeline guarantees that, given the same input source tree, two invocations on the same host produce byte-identical outputs — both the .manifest.json AND the .tar.gz.

Mechanisms:

  1. SOURCE_DATE_EPOCH=0— tar entries written with mtime 0.
  2. tar --owner=0 --group=0 --numeric-owner --format=ustar— no user/group from the host filesystem leaks into the archive.
  3. gzip -n -9(bash) / hand-rolled gzip writer with fixed header bytes (PowerShell) — no embedded filename, no timestamp, no platform-dependent OS byte.
  4. Lexical sort (LC_ALL=C sort / StringComparer.Ordinal) — file ordering is independent of locale.
  5. Hand-formatted JSON with stable key ordering — no jq / ConvertTo-Json dependency (both produce different whitespace across versions).
  6. No mktemp-derived names inside the archive — staging dirs are used only as scratch.

5.1 Cross-tool parity caveat

The manifest is byte-identical across assemble.sh and assemble.ps1 on the same input (verified in Sprint 20260513_011).

The archive bytes are NOT byte-identical across the two scripts. assemble.sh uses GNU tar, assemble.ps1 uses bsdtar; the two ustar emitters disagree on field padding. Consumers should rely on the manifest’s sha256 per-file hashes for cross-platform verification, not on the archive sha256.

5.2 Automated check

./devops/scripts/offline-kit/Tests/assemble-determinism-test.sh
powershell -ExecutionPolicy Bypass `
    -File .\devops\scripts\offline-kit\Tests\assemble-determinism-test.ps1

Each runs the matching assembler twice into separate output directories and asserts byte-equality. Exit codes: 0 pass, 1 manifest drift, 2 archive drift, 3 invocation failure.


6. Verification on the receiving side

# 1. Verify the archive sha256 matches what the upstream operator quoted.
sha256sum offline-kit-baseline-2026.05.13.tar.gz

# 2. Extract and verify each file against the manifest.
mkdir -p /tmp/kit && tar -xzf offline-kit-baseline-2026.05.13.tar.gz -C /tmp/kit
cd /tmp/kit

# 3. Spot-check a few files.
sha256sum datasets/trust-store/scanner-plugins/dev/index.json
#   -> compare against the "sha256" recorded in 0-manifest.json

# 4. Full pass: re-derive every hash and compare.
python3 - <<'PY'
import hashlib, json, pathlib, sys
m = json.load(open('0-manifest.json'))
bad = 0
for entry in m['files']:
    p = pathlib.Path(entry['path'])
    h = hashlib.sha256(p.read_bytes()).hexdigest()
    if h != entry['sha256']:
        print(f'MISMATCH {p}: got {h} expected {entry["sha256"]}', file=sys.stderr)
        bad += 1
print(f'{len(m["files"])} files; {bad} mismatches')
sys.exit(1 if bad else 0)
PY

7. Sample output

For a checkout at this sprint’s commit, against the worktree’s src/__Tests/__Datasets/ tree, the manifest looks like:

{
  "schemaVersion": "1.0",
  "kind": "stellaops.offline-kit.baseline",
  "version": "2026.05.13",
  "generatedAt": "1970-01-01T00:00:00Z",
  "sourceDateEpoch": 0,
  "files": [
    { "path": "datasets/dart/pubdev-index/README.md", "sha256": "299da50a…", "sizeBytes": 1883 },
    { "path": "datasets/dart/pubdev-index/args.json", "sha256": "48e649e7…", "sizeBytes": 103 },
    …
    { "path": "datasets/trust-store/scanner-plugins/dev/seed.hex", "sha256": "…", "sizeBytes": … },
    { "path": "plugins/scanner/analyzers/elixir/manifest.json", "sha256": "…", "sizeBytes": … }
  ]
}

The first datasets/ entry is dart/pubdev-index/README.md because ordinal sort orders uppercase R (0x52) before lowercase a (0x61).


8. Signing the manifest (follow-up, not in baseline)

The baseline emits an unsigned manifest. Operators who need a signed envelope can pass the manifest path through the dev signing tool added in Sprint 20260513_003:

dotnet run --project \
    src/Scanner/__Libraries/StellaOps.Scanner.Plugin.SigningTool \
    -- sign \
    --manifest out/offline-kit-baseline/offline-kit-baseline-2026.05.13.manifest.json \
    --key /path/to/ed25519.seed \
    --out  out/offline-kit-baseline/offline-kit-baseline-2026.05.13.manifest.signed.json

The signing tool accepts any well-formed JSON file; it base64-encodes the payload into a DSSE-style envelope and adds an Ed25519 signature. The verifier round-trips through ScannerPluginSignatureVerifier in the plugin-loader.

A future iteration of this baseline may add a --sign flag that does this inline; for now the two-step flow keeps the determinism contract decoupled from the signer-key supply chain.


9. CI lane and rotation cadence (Sprint 20260513_018)

The Sprint 011 baseline is the contract; Sprint 018 productionises it behind a Gitea Actions lane:

9.1 --ci mode and summary.json

When invoked with --ci, both assemble.sh and assemble.ps1:

summary.json schema (stellaops.offline-kit.baseline.ci-summary, schema version 1.0):

{
  "schemaVersion": "1.0",
  "kind": "stellaops.offline-kit.baseline.ci-summary",
  "version": "2026.05.13",
  "generatedAt": "1970-01-01T00:00:00Z",
  "sourceRoot": "/runner/_work/git.stella-ops.org/...",
  "outDir":     "/runner/_work/.../out/offline-kit-baseline",
  "fileCount": 52,
  "manifest": {
    "path":     "out/.../offline-kit-baseline-2026.05.13.manifest.json",
    "sha256":   "…",
    "sizeBytes": 8726
  },
  "archive": {
    "path":     "out/.../offline-kit-baseline-2026.05.13.tar.gz",
    "sha256":   "…",
    "sizeBytes": 17527
  },
  "manifestOnly": false,
  "signing":      null
}

When the CI workflow’s signing step succeeds, it overwrites signing with an object of the form { "mode": "production"|"ci-dry-run", "signedManifestPath": "…" }. A null signing field means the kit shipped unsigned (the default this sprint — see Sprint 018 §Decisions & Risks).

9.2 Rotation cadence

Detailed rotation guidance — when to bump the kit, who custodies the production signing key, how to publish a new release, and how consumers verify — is documented in the dedicated rotation runbook:

offline-kit-rotation.md


10. Cross-references