Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c15fca9
ci(auto-merge): wait for every required check and dispatch master lan…
Amantux Oct 11, 2026
89f6641
docs(readme): document trusted auto-merge two-workflow wait and post-…
Amantux Oct 11, 2026
629624f
fix(safety): consume format safety token atomically before the job
Amantux Oct 11, 2026
071cece
fix(auth): fail closed at startup in production on open auth gates
Amantux Oct 11, 2026
b59c7c7
test(hardware): opt-in appliance soft-reboot persistence test
Amantux Oct 11, 2026
8ab0ce5
ci(hardware-destructive): opt-in reboot persistence step
Amantux Oct 11, 2026
7ad591d
feat(hardware): add read-only appliance preflight tool
Amantux Oct 11, 2026
56066ce
test(hardware): unit-test every preflight check with fakes
Amantux Oct 11, 2026
2d8e8e3
fix(auth): make the test-runner route dev-only and loopback-targeted
Amantux Oct 11, 2026
24e322d
docs(runbooks): document the opt-in reboot persistence setting
Amantux Oct 11, 2026
e7798d3
ci(hardware): run the preflight tool instead of inline env checks
Amantux Oct 11, 2026
527da13
ci(auto-merge): address review — keep retries, Actions-only checks, f…
Amantux Oct 11, 2026
c85cd40
docs(readme): state the auto-merge fallback list and master-tip dispa…
Amantux Oct 11, 2026
dc1cee2
Merge fleet4/reboot-setting: opt-in appliance soft-reboot persistence…
Amantux Oct 11, 2026
532f1a6
build: add make hw-preflight target
Amantux Oct 11, 2026
bb7422e
docs(runbook): add §0 Day one checklist and reference the preflight
Amantux Oct 11, 2026
bd295c3
feat(deploy): bind compose to loopback; add opt-in production LAN ove…
Amantux Oct 11, 2026
e1c23bc
Merge fleet4/automerge: wait for every required check, dispatch maste…
Amantux Oct 11, 2026
69966eb
ci: only PR runs cancel in-progress runs; dispatched master runs neve…
Amantux Oct 11, 2026
decea9a
feat(auth): warn on insecure Flask cookies in production; document LA…
Amantux Oct 11, 2026
756784d
fix(nas): refuse ingest start when pool_id differs from the plan's bo…
Amantux Oct 11, 2026
afa04f2
feat(safety): enforce scratch-media allowlist in FormatService on rea…
Amantux Oct 11, 2026
e4ee030
fix(nas): book share-push capacity before registering the plan
Amantux Oct 11, 2026
9766472
feat(safety): real backend requires an API token; accept simulator alias
Amantux Oct 11, 2026
e6d25db
docs(safety): document new gates; flag vacuous operator_acknowledgment
Amantux Oct 11, 2026
944e1c2
Merge fleet4/harden-hw: single-use format token, in-code scratch allo…
Amantux Oct 11, 2026
362ce3d
fix(nas): roll back the capacity booking when an ingest start fails
Amantux Oct 11, 2026
971af9d
docs(decisions): mark the capacity start-path risks resolved
Amantux Oct 11, 2026
dae3aee
Merge fleet4/harden-auth: fail-closed production startup, dev-only te…
Amantux Oct 11, 2026
8cffcdd
Merge fleet4/appliance-preflight: read-only day-one preflight tool, l…
Amantux Oct 11, 2026
8eecdc9
ci(hardware): mint a masked per-run API token in every real lane
Amantux Oct 11, 2026
c93ec16
Merge fleet4/capacity-safety: pool mismatch 409, book-before-register…
Amantux Oct 11, 2026
6665fca
refactor(hardware): share the read-only ApplianceClient between the l…
Amantux Oct 11, 2026
14649ab
Merge fleet4/lanes-token: per-run API token in real lanes, shared App…
Amantux Oct 11, 2026
e9493b0
fix(config): redact credentials from OPENBLADE_DB_URL in validation f…
Amantux Oct 11, 2026
813659a
fix(nas): ingest refuses to auto-format on a real backend
Amantux Oct 11, 2026
cd59035
fix(format): normalise barcodes once at the FormatService boundary
Amantux Oct 11, 2026
a3cdeab
fix(hardware): preflight FAILs (exit 1) when run armed
Amantux Oct 11, 2026
e253ea7
docs(agents): ingest never formats on a real backend
Amantux Oct 11, 2026
62494d5
fix(auth): reject blank, short (<12) and default AML passwords
Amantux Oct 11, 2026
70a71b3
ci(auto-merge): require owner approval on head SHA for non-owner trus…
Amantux Oct 11, 2026
48631cd
test(format): claim a safety token from two independent repositories …
Amantux Oct 11, 2026
96ac4d8
test: armed-real-backend fixtures supply the now-required API token
Amantux Oct 11, 2026
3670034
fix(capacity): isolate rollback cleanups so the original start error …
Amantux Oct 11, 2026
4c40f5c
Merge fleet4/fix-safety: no auto-format on real backend, barcode norm…
Amantux Oct 11, 2026
5bff565
Merge fleet4/fix-misc2: URL redaction, password strength gate, owner …
Amantux Oct 11, 2026
fdb40d4
ci(operability): positive-control production config carries an API token
Amantux Oct 11, 2026
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
169 changes: 164 additions & 5 deletions .github/workflows/auto-merge-trusted.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,135 @@ name: Auto-merge trusted PRs
# dangerous-triggers is ignored on `on:` below: this workflow runs only the GitHub API via
# github-script on the trusted-author allowlist; never checks out or executes
# PR code, which is what makes workflow_run dangerous.
#
# Two required checks live in two workflows (`ci-gate` in CI, `operability-gate` in
# operability), so this listens to BOTH. Whichever finishes first sees the other's gate
# still pending and exits; the second workflow's completion is the retry. After a merge
# it dispatches CI and operability on master, because a merge made with GITHUB_TOKEN
# emits no `push` run (so `full-suite` would never run on the merge commit).
on: # zizmor: ignore[dangerous-triggers] -- see comment above
workflow_run:
workflows: ["CI"]
workflows: ["CI", "operability"]
types: [completed]

permissions:
contents: read

jobs:
merge-trusted-pr:
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request'
# No conclusion filter here: decide() enforces success per required check, and
# skipping non-success completions would drop the only retry a waiting PR has.
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
# Both workflows can finish together; serialise so the loser sees the PR merged.
concurrency:
group: auto-merge-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
actions: write
steps:
- name: Merge trusted PRs targeting master
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
// decide(requiredContexts, checkRuns, statuses) -> { decision, pending, failed }
// decision is "merge" | "wait" | "fail". Pure: no API calls.
// checkRuns: [{ id, name, status, conclusion }] (all runs on the head SHA)
// statuses: [{ context, state }] (combined status: latest per context)
// A required context counts as passed ONLY on conclusion/state "success".
// The newest check run (highest id) per name wins, so a re-run supersedes.
// A context with no run and no status yet is "pending" (its workflow has
// not reported; that workflow's completion re-triggers this job).
//
// Worked examples (required = ["ci-gate", "operability-gate"]):
// 1. all green: runs [{id:1,name:"ci-gate",status:"completed",conclusion:"success"},
// {id:2,name:"operability-gate",status:"completed",conclusion:"success"}], statuses []
// -> { decision: "merge", pending: [], failed: [] }
// 2. one pending (PR #47): runs [{id:1,name:"ci-gate",status:"completed",
// conclusion:"success"}, {id:2,name:"operability-gate",status:"in_progress",
// conclusion:null}] -> { decision: "wait", pending: ["operability-gate"], failed: [] }
// 3. one failed: runs [{id:1,name:"ci-gate",status:"completed",conclusion:"failure"},
// {id:2,name:"operability-gate",status:"completed",conclusion:"success"}]
// -> { decision: "fail", pending: [], failed: ["ci-gate (failure)"] }
function decide(requiredContexts, checkRuns, statuses) {
const pending = [];
const failed = [];
for (const ctx of requiredContexts) {
const runs = checkRuns.filter((r) => r.name === ctx).sort((a, b) => b.id - a.id);
const status = statuses.find((s) => s.context === ctx);
if (runs.length) {
const run = runs[0];
if (run.status !== "completed") pending.push(ctx);
else if (run.conclusion !== "success") failed.push(`${ctx} (${run.conclusion})`);
} else if (status) {
if (status.state === "pending") pending.push(ctx);
else if (status.state !== "success") failed.push(`${ctx} (${status.state})`);
} else {
pending.push(ctx);
}
}
const decision = failed.length ? "fail" : pending.length ? "wait" : "merge";
return { decision, pending, failed };
}

// reviewGate(author, reviews, headSha) -> { ok, reason }
// Pure. The owner merges directly. Any other trusted author (Copilot,
// whose PR content is steered by issue text) additionally needs the
// owner's latest substantive review to be APPROVED on the exact head
// SHA; a later push, CHANGES_REQUESTED or DISMISSED review resets it.
const reviewOwner = "Amantux";
function reviewGate(author, reviews, headSha) {
if (author === reviewOwner) return { ok: true, reason: "owner" };
let latest = null;
for (const r of reviews) {
if (!r.user || r.user.login !== reviewOwner) continue;
if (r.state === "COMMENTED" || r.state === "PENDING") continue;
latest = r; // listReviews is chronological
}
if (!latest || latest.state !== "APPROVED") {
return { ok: false, reason: `no ${reviewOwner} approval` };
}
if (latest.commit_id !== headSha) {
return { ok: false, reason: `${reviewOwner} approval is on an older commit` };
}
return { ok: true, reason: "approved" };
}

const owner = context.repo.owner;
const repo = context.repo.repo;
const allowedAuthors = new Set(["Amantux", "Copilot"]);
const fallbackRequired = ["ci-gate", "operability-gate"];
const prs = context.payload.workflow_run.pull_requests || [];

if (!prs.length) {
core.info("No pull requests attached to this CI run.");
core.info("No pull requests attached to this workflow run.");
return;
}

async function requiredContextsFor(branch) {
try {
const { data } = await github.rest.repos.getStatusChecksProtection({
owner,
repo,
branch,
});
if (data.contexts && data.contexts.length) return data.contexts;
return fallbackRequired;
} catch (error) {
// GITHUB_TOKEN cannot be granted `administration: read`, so this is
// usually 403/404; fall back to the known branch-protection list.
if (error.status === 403 || error.status === 404) {
core.info(`Branch protection not readable (${error.status}); using ${fallbackRequired.join(", ")}.`);
return fallbackRequired;
}
throw error;
}
}

for (const prRef of prs) {
const number = prRef.number;
const { data: pr } = await github.rest.pulls.get({
Expand All @@ -60,15 +157,77 @@ jobs:
continue;
}

const required = await requiredContextsFor(pr.base.ref);
// Only GitHub Actions check runs count, so another app cannot post a
// look-alike gate name.
const checkRuns = (
await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: pr.head.sha,
per_page: 100,
})
).filter((r) => r.app && r.app.slug === "github-actions");
const { data: combined } = await github.rest.repos.getCombinedStatusForRef({
owner,
repo,
ref: pr.head.sha,
per_page: 100,
});
const verdict = decide(required, checkRuns, combined.statuses || []);
if (verdict.decision === "wait") {
core.notice(`Not merging #${number} yet: waiting for ${verdict.pending.join(", ")}; the other workflow's completion will re-run this.`);
continue;
}
if (verdict.decision === "fail") {
core.notice(`Not merging #${number}: required check(s) not successful: ${verdict.failed.join(", ")}.`);
continue;
}

const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: number,
per_page: 100,
});
const review = reviewGate(pr.user.login, reviews, pr.head.sha);
if (!review.ok) {
core.notice(`Not merging #${number}: ${review.reason} on ${pr.head.sha}.`);
continue;
}

try {
await github.rest.pulls.merge({
owner,
repo,
pull_number: number,
merge_method: "squash",
sha: pr.head.sha,
});
core.info(`Merged trusted PR #${number}.`);
} catch (error) {
core.warning(`Unable to merge #${number}: ${error.message}`);
continue;
}

// A GITHUB_TOKEN merge emits no `push` run; workflow_dispatch is the one
// event this token can trigger, so run the master-only lanes explicitly.
const dispatchFailures = [];
for (const workflowId of ["ci.yml", "operability.yml"]) {
try {
await github.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: workflowId,
ref: "master",
});
core.info(`Dispatched ${workflowId} on master after merging #${number}.`);
} catch (error) {
dispatchFailures.push(`${workflowId}: ${error.message}`);
}
}
if (dispatchFailures.length) {
// Fail red: without these runs full-suite never covers the merge.
core.setFailed(`Merged #${number} but could not dispatch master lanes: ${dispatchFailures.join("; ")}`);
}
}
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ on:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# PR runs supersede each other; dispatched master runs (post-merge full-suite /
# operability) must never cancel one another or a merge ends up with no result.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read
Expand Down
60 changes: 50 additions & 10 deletions .github/workflows/hardware-destructive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,39 @@ jobs:

- run: pip install -e '.[dev]'

# Real mode refuses to start without an API token (openblade/config.py
# raises RealHardwareDisabledError). Mint a throwaway one per run, masked.
- name: Mint a per-run API token
run: |
set -euo pipefail
token=$(openssl rand -hex 32)
echo "::add-mask::$token"
echo "OPENBLADE_API_TOKEN=$token" >> "$GITHUB_ENV"

# A rig lane must never go green by skipping: the rig tests skip when
# these are unset, so refuse to run without them. OPENBLADE_CHANGER_DEVICE
# and OPENBLADE_DRIVE_DEVICES come from the runner host's environment.
- name: Preflight — rig configuration present
- name: Preflight — rig ready (python -m tools.hardware.preflight)
# Read-only tool (env, devices, inquiry serials, changer, allowlist,
# appliance login). It must never run armed: blank the job-level enable.
env:
OPENBLADE_REAL_HARDWARE_ENABLED: ""
run: |
set -euo pipefail
missing=()
for v in OPENBLADE_APPLIANCE_URL OPENBLADE_APPLIANCE_USER OPENBLADE_APPLIANCE_PASSWORD OPENBLADE_CHANGER_DEVICE OPENBLADE_DRIVE_DEVICES; do
if [ -z "${!v:-}" ]; then missing+=("$v"); fi
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::rig lane misconfigured, missing: ${missing[*]}"
if ! python -m tools.hardware.preflight --lane destructive --json > preflight.json; then
cat preflight.json
echo "::error::rig lane preflight failed; see the FAIL checks above"
exit 1
fi

- name: Upload preflight report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: preflight-destructive-${{ github.run_id }}
path: preflight.json
if-no-files-found: ignore

- name: Allowlist — refuse any cartridge outside the sacrificial set
id: allowlist
run: |
Expand Down Expand Up @@ -136,9 +154,6 @@ jobs:
- name: Gate — fail on any skip or zero passes
run: python -m tools.hardware.junit_gate reports/*.xml

# Reboot persistence is a MANUAL step (the runner cannot power-cycle the
# appliance): see docs/runbooks/hardware-lanes.md "Reboot persistence".

- name: Recovery report must be clean
# Only once the allowlist passed: before that, nothing was touched.
if: always() && steps.allowlist.outcome == 'success'
Expand Down Expand Up @@ -176,6 +191,31 @@ jobs:
python -m tools.hardware.snapshot capture --out snapshots/after.json
python -m tools.hardware.snapshot diff snapshots/before.json snapshots/after.json --out snapshots/diff.json

# Soft reboot through the documented AML endpoint. Opt-in per repo: runs
# only when vars.HARDWARE_REBOOT_TESTS == 'true' AND every earlier step
# succeeded (success(), never always()) — i.e. the tests, the gate, the
# recovery report and the lane's after-snapshot (taken first so the lane
# diff is not polluted by the reboot; the test diffs its own before/after).
# Disruptive but non-destructive to media; same environment approval as
# the rest of the job. A hard power-cycle stays manual:
# docs/runbooks/hardware-lanes.md "Reboot persistence".
- name: Reboot persistence (AML soft reboot)
if: ${{ success() && vars.HARDWARE_REBOOT_TESTS == 'true' }}
# Bounds the test's own waits (300s down + 600s up) plus snapshots.
timeout-minutes: 20
env:
OPENBLADE_REBOOT_TESTS: enabled
# Pinned: this step always verifies TLS, whatever the rig var says.
OPENBLADE_APPLIANCE_INSECURE: "false"
run: |
set -euo pipefail
python -m pytest tests/hardware/test_reboot_persistence.py -m real_hardware -q -p no:cacheprovider --tb=short -rs --junitxml=reports/test_reboot_persistence.xml
python -m tools.hardware.junit_gate reports/test_reboot_persistence.xml

- name: Reboot persistence not enabled
if: ${{ success() && vars.HARDWARE_REBOOT_TESTS != 'true' }}
run: echo "::notice::Reboot persistence skipped — set repo variable HARDWARE_REBOOT_TESTS=true to opt in (docs/runbooks/hardware-lanes.md)."

- name: Upload snapshots
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
Expand Down
32 changes: 25 additions & 7 deletions .github/workflows/hardware-library-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,39 @@ jobs:

- run: pip install -e '.[dev]'

# Real mode refuses to start without an API token (openblade/config.py
# raises RealHardwareDisabledError). Mint a throwaway one per run, masked.
- name: Mint a per-run API token
run: |
set -euo pipefail
token=$(openssl rand -hex 32)
echo "::add-mask::$token"
echo "OPENBLADE_API_TOKEN=$token" >> "$GITHUB_ENV"

# A rig lane must never go green by skipping: the rig tests skip when
# these are unset, so refuse to run without them. OPENBLADE_CHANGER_DEVICE
# and OPENBLADE_DRIVE_DEVICES come from the runner host's environment.
- name: Preflight — rig configuration present
- name: Preflight — rig ready (python -m tools.hardware.preflight)
# Read-only tool (env, devices, inquiry serials, changer, allowlist,
# appliance login). It must never run armed: blank the job-level enable.
env:
OPENBLADE_REAL_HARDWARE_ENABLED: ""
run: |
set -euo pipefail
missing=()
for v in OPENBLADE_APPLIANCE_URL OPENBLADE_APPLIANCE_USER OPENBLADE_APPLIANCE_PASSWORD OPENBLADE_CHANGER_DEVICE OPENBLADE_DRIVE_DEVICES; do
if [ -z "${!v:-}" ]; then missing+=("$v"); fi
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::rig lane misconfigured, missing: ${missing[*]}"
if ! python -m tools.hardware.preflight --lane readonly --json > preflight.json; then
cat preflight.json
echo "::error::rig lane preflight failed; see the FAIL checks above"
exit 1
fi

- name: Upload preflight report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: preflight-readonly-${{ github.run_id }}
path: preflight.json
if-no-files-found: ignore

- name: Snapshot inventory + drive state (before)
run: |
set -euo pipefail
Expand Down
Loading
Loading