diff --git a/.github/workflows/fat-direct-exe.yml b/.github/workflows/fat-direct-exe.yml new file mode 100644 index 000000000..6b31236b3 --- /dev/null +++ b/.github/workflows/fat-direct-exe.yml @@ -0,0 +1,165 @@ +name: Publish direct EXE FAT preview (manual only) + +# CI artifact downloads are always ZIP. PRs run ONLY read-only tests; +# publishing a public, non-production GitHub prerelease is manual-only. +on: + pull_request: + paths: + - '.github/workflows/fat-direct-exe.yml' + - 'scripts/verify-fat-direct-exe.py' + - 'scripts/test-fat-direct-exe.py' + workflow_dispatch: + inputs: + tested_sha: + description: CI-qualified exact 40-character source SHA + type: string + required: true + build_run_id: + description: Successful exact-SHA Build ARSAS workflow run ID + type: string + required: true + installer_run_id: + description: Optional successful exact-SHA installer run ID; 0 skips + type: string + required: true + default: '0' + public_preview_ack: + description: Type PUBLISH PUBLIC NONPRODUCTION FAT EXE to authorize public FAT-only assets + type: string + required: true + +permissions: + contents: read + actions: read + +concurrency: + group: fat-direct-exe-preview + cancel-in-progress: false + +jobs: + verifier-tests: + name: Reject stale, failed and disguised-fat-build artifacts + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout for safe offline verifier tests + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Run positive and negative direct EXE publication guard tests + run: python3 scripts/test-fat-direct-exe.py + + preview: + name: Verify tested PE bytes before manual immutable FAT prerelease + permissions: + contents: write + actions: read + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: fat-preview-publication + env: + GH_TOKEN: ${{ github.token }} + TESTED_SHA: ${{ inputs.tested_sha }} + BUILD_RUN_ID: ${{ inputs.build_run_id }} + INSTALLER_RUN_ID: ${{ inputs.installer_run_id }} + PUBLIC_ACK: ${{ inputs.public_preview_ack }} + steps: + - name: Checkout approved workflow and verifier, not untrusted candidate scripts + uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1 + + - name: Verify explicit consent and exact HEAD full CI + shell: bash + run: | + set -euo pipefail + python3 scripts/test-fat-direct-exe.py + [[ "$PUBLIC_ACK" == "PUBLISH PUBLIC NONPRODUCTION FAT EXE" ]] || { + echo "An explicit public preview acknowledgement is required." >&2; exit 1; + } + [[ "$TESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 + [[ "$BUILD_RUN_ID" =~ ^[0-9]+$ ]] || exit 1 + [[ "$INSTALLER_RUN_ID" =~ ^[0-9]+$ ]] || exit 1 + gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$TESTED_SHA&per_page=100" > ci.json + jq --arg sha "$TESTED_SHA" -e ' + [.workflow_runs[] | select(.head_sha == $sha) | + select(.event == "pull_request" or .event == "push")] as $runs | + ($runs | length >= 10) and + ([$runs[] | select(.status != "completed" or .conclusion != "success")] | length == 0) + ' ci.json || { echo "All exact-SHA CI workflows must be successful." >&2; exit 1; } + TAG="fat-${TESTED_SHA:0:12}" + echo "PREVIEW_TAG=$TAG" >> "$GITHUB_ENV" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Immutable preview already exists; never overwrite an asset." >&2; exit 1 + fi + + - name: Verify downloaded Windows portable is the actual CI-tested EXE + shell: bash + run: | + set -euo pipefail + mkdir -p staged/portable published + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN_ID" > build-run.json + gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name ARSAS-win-x64-portable-single-exe --dir staged/portable + mapfile -t files < <(find staged/portable -type f -name '*.exe') + [[ "${#files[@]}" -eq 1 ]] || { echo "Expected exactly one EXE." >&2; exit 1; } + python3 scripts/verify-fat-direct-exe.py \ + --run-json build-run.json --sha "$TESTED_SHA" --workflow "Build ARSAS" \ + --exe "${files[0]}" --output published/portable-provenance.json + cp "${files[0]}" "published/ARSAS-FAT-${TESTED_SHA:0:12}-win-x64-portable.exe" + + - name: Optionally verify separately successful exact-SHA installer + shell: bash + run: | + set -euo pipefail + if [[ "$INSTALLER_RUN_ID" == 0 ]]; then exit 0; fi + mkdir -p staged/installer + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$INSTALLER_RUN_ID" > installer-run.json + gh run download "$INSTALLER_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --pattern 'ARSAS-*-win-x64-installer' --dir staged/installer + mapfile -t files < <(find staged/installer -type f -name '*-setup.exe') + [[ "${#files[@]}" -eq 1 ]] || { echo "Expected exactly one installer." >&2; exit 1; } + python3 scripts/verify-fat-direct-exe.py \ + --run-json installer-run.json --sha "$TESTED_SHA" \ + --workflow "Validate ARSAS Windows installer" \ + --exe "${files[0]}" --output published/installer-provenance.json + cp "${files[0]}" "published/ARSAS-FAT-${TESTED_SHA:0:12}-win-x64-setup.exe" + + - name: Publish only approved non-production direct EXE preview assets + shell: bash + run: | + set -euo pipefail + (cd published && sha256sum ./*.exe > SHA256SUMS.txt && sha256sum -c SHA256SUMS.txt) + cat > published/NOT-PRODUCTION.txt < release-notes.md <> "$GITHUB_STEP_SUMMARY" diff --git a/docs/FAT_DIRECT_EXE_DOWNLOADS.md b/docs/FAT_DIRECT_EXE_DOWNLOADS.md new file mode 100644 index 000000000..90f5a84cd --- /dev/null +++ b/docs/FAT_DIRECT_EXE_DOWNLOADS.md @@ -0,0 +1,49 @@ +# Direct EXE downloads for engineering FAT — opt-in public preview + +## Why a PR build link still returns ZIP + +The Windows Build ARSAS workflow already produces a real x64 single-file portable \`.exe\`. GitHub Actions stores and **downloads all workflow artifacts as ZIP**, even when only one EXE is uploaded. Changing upload-artifact names or extensions does not change this behavior. Do not label an Actions artifact URL as a direct EXE. + +Published GitHub **Release assets**, by contrast, give direct binary URLs ending in \`.exe\`. ARSAS stable v1.6.41 already offers official direct portable/installer EXEs; these are **historical stable files**, not newer #659 FAT binaries. + +## Proposed safe workflow + +\`.github/workflows/fat-direct-exe.yml\` is a **manual-only, opt-in** preview publisher. Pull requests may run only **read-only offline verifier tests**, with `contents: read`; the privileged publishing job has **no push, tag, PR, or workflow_run publishing trigger** and is gated to a manual dispatch on the default branch. It can only be dispatched after the infrastructure file exists on the default branch and after the owner has explicitly approved **public non-production distribution**. Do not merge any unfinished P0 application/engine changes merely to install this infrastructure. An optional GitHub Environment named \`fat-preview-publication\` can require a human reviewer. + +Required inputs: +- \`tested_sha\`: exact qualified 40-char candidate source commit. +- \`build_run_id\`: completed successful Build ARSAS run for that SHA; the workflow verifies its run identity, full set of exact-SHA workflow gates, downloaded PE signatures, and SHA-256. +- \`installer_run_id\`: optional separate successful installer validation run for the **same exact SHA**; use \`0\` when no installer exists. +- \`public_preview_ack\`: exactly \`PUBLISH PUBLIC NONPRODUCTION FAT EXE\`. This is a deliberate public-distribution authorization; without it the workflow aborts. + +A successful explicitly authorized run publishes a **public GitHub prerelease** named \`fat-\`, never a stable release or "latest", with: +- \`ARSAS-FAT--win-x64-portable.exe\` +- \`ARSAS-FAT--win-x64-setup.exe\` **only if a fully qualified exact-SHA installer exists** +- \`SHA256SUMS.txt\`, source/build provenance receipts and a NOT-PRODUCTION warning. + +Direct URLs then have the form: + +\`\`\`text +https://github.com/masarray/arsas/releases/download/fat-/ARSAS-FAT--win-x64-portable.exe +https://github.com/masarray/arsas/releases/download/fat-/ARSAS-FAT--win-x64-setup.exe +\`\`\` + +These are **URL templates, not existing download links** until the explicitly approved run finishes. Every SHA gets a fresh immutable tag; never overwrite existing preview binaries or stable v1.6.40/v1.6.41 assets. Do not change \`.release/windows.json\`, publish-verified production release metadata or \`main\` engineering acceptance through this workflow. + +## Immediate #659 status + +Current P0 Data Model #659 exact tested source: +\`1371c2990c40356acb1794bfa42c76e408421b47\`. +Windows 11/11 CI green, portable single-file artifact verified, but **Actions ZIP**: +https://github.com/masarray/arsas/actions/runs/39525163418/artifacts/12312721694 + +There is **no #659 direct EXE release asset yet**. Publishing a public prerelease (even explicitly marked FAT-only) must be approved by the repository owner before it runs. Installer for this exact candidate must pass its own Windows installer workflow before the optional setup.exe is made public. Do not substitute an older stable installer. + +As a separate reminder, a different, already published production version currently has direct EXEs: +https://github.com/masarray/arsas/releases/download/v1.6.41/ARSAS-Windows-x64-Portable.exe +https://github.com/masarray/arsas/releases/download/v1.6.41/ARSAS-Windows-x64-Setup.exe +These assets do NOT contain the latest #659 Data Model patch. + +## Security and regression + +Run \`python scripts/test-fat-direct-exe.py\` before any preview publication. The tests reject SHA mismatch, incomplete/failing workflow run, disguised ZIP, and wrong/missing PE signature. The publication script also fails closed if fewer than ten exact-SHA workflow checks succeeded, if any are incomplete/failing, if the preview tag exists, or if any candidate executable failed independent local SHA sealing. No IED connection, read, write, release promotion or application runtime change is made. diff --git a/scripts/test-fat-direct-exe.py b/scripts/test-fat-direct-exe.py new file mode 100644 index 000000000..9d6ecc157 --- /dev/null +++ b/scripts/test-fat-direct-exe.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Offline regression for the manual FAT EXE publication guard.""" +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("verify-fat-direct-exe.py") +spec = importlib.util.spec_from_file_location("fat_direct_exe", SCRIPT) +guard = importlib.util.module_from_spec(spec) +spec.loader.exec_module(guard) + +SHA = "a" * 40 + + +def run(name="Build ARSAS", sha=SHA, status="completed", conclusion="success"): + return {"id": 123, "head_sha": sha, "name": name, "status": status, + "conclusion": conclusion, "event": "pull_request"} + + +class FatDirectExeGuardTests(unittest.TestCase): + def test_exact_qualified_build_and_installer(self): + guard.validate_run(run(), SHA, "Build ARSAS") + guard.validate_run(run("Validate ARSAS Windows installer"), + SHA, "Validate ARSAS Windows installer") + + def test_reject_incomplete_failed_stale_and_wrong_workflow(self): + variants = [run(sha="b" * 40), run(status="in_progress"), + run(conclusion="failure"), run(name="Unrelated workflow")] + for r in variants: + with self.subTest(r=r), self.assertRaises(ValueError): + guard.validate_run(r, SHA, "Build ARSAS") + with self.assertRaises(ValueError): + guard.validate_run(run(), "abc", "Build ARSAS") + + def test_check_pe_signature_and_sha(self): + with tempfile.TemporaryDirectory() as directory: + exe = Path(directory, "qualified.exe") + data = bytearray(1024 * 1024 + 1) + data[:2] = b"MZ" + data[0x3C:0x40] = (0x80).to_bytes(4, "little") + data[0x80:0x84] = b"PE\x00\x00" + exe.write_bytes(data) + import hashlib + self.assertEqual(hashlib.sha256(data).hexdigest(), guard.check_exe(exe)) + data[0x80] = 0 + exe.write_bytes(data) + with self.assertRaises(ValueError): + guard.check_exe(exe) + + def test_reject_zip_disguised_as_exe(self): + with tempfile.TemporaryDirectory() as directory: + exe = Path(directory, "fake.exe") + exe.write_bytes(b"PK\x03\x04" + b"\0" * (1024 * 1024)) + with self.assertRaises(ValueError): + guard.check_exe(exe) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-fat-direct-exe.py b/scripts/verify-fat-direct-exe.py new file mode 100644 index 000000000..0d58c9f17 --- /dev/null +++ b/scripts/verify-fat-direct-exe.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Fail-closed FAT EXE release input verification; never publishes anything.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + +SHA40 = re.compile(r"[a-f0-9]{40}\Z") +RUNS = {"Build ARSAS", "Validate ARSAS Windows installer"} + + +def validate_run(run: dict, sha: str, workflow: str) -> None: + if not SHA40.fullmatch(sha): + raise ValueError("Expected exact 40-character lowercase source SHA") + if workflow not in RUNS: + raise ValueError("Unrecognized verified Windows workflow") + if (run.get("head_sha") != sha or run.get("name") != workflow + or run.get("status") != "completed" + or run.get("conclusion") != "success"): + raise ValueError("Run is unqualified or is not the exact requested revision") + if run.get("event") not in {"pull_request", "push", "workflow_dispatch"}: + raise ValueError("Unrecognized source-build event") + + +def check_exe(path: Path) -> str: + if not path.is_file() or path.stat().st_size < 1024 * 1024: + raise ValueError("Missing or unexpectedly small Windows executable") + with path.open("rb") as stream: + header = stream.read(64) + if len(header) != 64 or header[:2] != b"MZ": + raise ValueError("File is not a Windows PE executable") + pe_offset = int.from_bytes(header[0x3C:0x40], "little") + if pe_offset < 64 or pe_offset > 1024 * 1024: + raise ValueError("Invalid Windows PE header offset") + stream.seek(pe_offset) + if stream.read(4) != b"PE\x00\x00": + raise ValueError("Missing Windows PE signature") + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> None: + cli = argparse.ArgumentParser() + cli.add_argument("--run-json", type=Path, required=True) + cli.add_argument("--sha", required=True) + cli.add_argument("--workflow", choices=sorted(RUNS), required=True) + cli.add_argument("--exe", type=Path, required=True) + cli.add_argument("--output", type=Path, required=True) + args = cli.parse_args() + run = json.loads(args.run_json.read_text(encoding="utf-8")) + validate_run(run, args.sha, args.workflow) + digest = check_exe(args.exe) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps({ + "testedSourceSha": args.sha, "workflow": args.workflow, + "workflowRunId": run.get("id"), "buildEvent": run.get("event"), + "file": args.exe.name, "fileSizeBytes": args.exe.stat().st_size, + "sha256": digest, "channel": "public-prerelease-FAT-only", + "fieldAcceptance": False, "productionPromotion": False, + }, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main()