diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..50c7e89 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + macos-core: + name: macos-core + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install Python test dependencies + run: | + python -m venv .venv + .venv/bin/python -m pip install --disable-pip-version-check --no-input \ + -e packages/drawio-exporter \ + -e 'services/mining-core[dev]' \ + -e 'services/local-api[dev]' + + - name: Install locked desktop dependencies + run: npm --prefix apps/desktop ci --ignore-scripts + + - name: Verify Rust lockfile + run: cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + + - name: Run repository quality gates + run: | + ./scripts/test.sh + ./scripts/lint.sh + ./scripts/check_licenses.sh + ./scripts/check_no_external_network.sh + + macos-package-smoke: + name: macos-package-smoke + needs: macos-core + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install desktop dependencies + run: npm --prefix apps/desktop ci --ignore-scripts + + - name: Build unsigned package smoke artifact + run: OPSMINEFLOW_SKIP_CHECKS=1 ./scripts/package_macos.sh + + - name: Verify package checksums + run: | + shopt -s nullglob + artifacts=(dist/macos/OpsMineFlow.app.zip dist/macos/*.dmg) + if [[ ${#artifacts[@]} -ne 2 ]]; then + echo "Expected one app zip and one dmg artifact." + printf '%s\n' "${artifacts[@]}" + exit 1 + fi + test -s dist/macos/SHA256SUMS.txt + for artifact in "${artifacts[@]}"; do + test -s "$artifact" + grep -F -- "$(basename "$artifact")" dist/macos/SHA256SUMS.txt >/dev/null + done + cd dist/macos + shasum -a 256 -c SHA256SUMS.txt diff --git a/README.ja.md b/README.ja.md index fa64a85..fadb7a4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,6 +4,8 @@ OpsMineFlow は、業務効率化コンサルティングの As-Is 調査、業務棚卸し、業務プロセス可視化、ボトルネック分析、自動化候補抽出、報告書ドラフト作成を支援する、Mac向けローカルファーストOSSだよ。 +CIの状態、必須check、main保護の手順は [CI / main branch quality gate](docs/operations/CI.md) にまとめてる。 + ## プロダクト概要 商用SaaS契約なしで、CSV/JSONイベントログや任意のActivityWatchエクスポートをローカルに取り込み、業務フローと改善候補を可視化する。 diff --git a/README.md b/README.md index f206acf..eec4221 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ OpsMineFlow is a local-first task mining and process mining assistant for macOS consultants. It helps with As-Is discovery, work inventory, process visualization, bottleneck analysis, automation candidate discovery, and consulting report drafts without requiring commercial SaaS contracts. +CI status, required checks, and the repository protection procedure are documented in [CI / main branch quality gate](docs/operations/CI.md). + ## Why OpsMineFlow Consulting teams often need to understand real work before proposing BPR, RPA, system renewal, or operating model changes. OpsMineFlow focuses on consent-based, local-only analysis that can be explained to client security, legal, and business teams. diff --git a/docs/operations/CI.md b/docs/operations/CI.md new file mode 100644 index 0000000..476fd84 --- /dev/null +++ b/docs/operations/CI.md @@ -0,0 +1,38 @@ +# CI / main branch quality gate + +## Purpose + +The `CI` workflow is the required macOS quality gate for every pull request and every push to `main`. It uses no repository secrets, defaults to read-only repository permissions, and does not publish artifacts, tags, releases, or telemetry. + +The stable required check names are **`macos-core`** and **`macos-package-smoke`**. Keep those names stable when editing the workflow; GitHub main-branch protection references them exactly. + +## What CI verifies + +- Python 3.11 virtual-environment installation and Python test dependencies +- `npm ci` against `apps/desktop/package-lock.json`, which rejects Node dependency/lockfile drift +- `cargo check --locked` for the Tauri desktop shell +- Swift type checking through `./scripts/test.sh` +- `./scripts/test.sh` +- `./scripts/lint.sh` +- `./scripts/check_licenses.sh` +- `./scripts/check_no_external_network.sh` +- unsigned `.app`/`.dmg` package smoke plus `SHA256SUMS.txt` validation + +The current workflow runs on GitHub's supported `macos-15` Apple Silicon runner. It proves that each artifact can be produced on that architecture; it does not establish an Intel support guarantee or a universal binary. #78 owns the v1 architecture policy and any Intel/universal release verification. + +Python dependencies still use the project version constraints during this first CI slice. Hash-pinned Python dependency locking, SBOM, vulnerability scanning, and provenance are owned by #84; do not treat this workflow as satisfying those supply-chain controls. + +## GitHub repository setting + +After the workflow has run successfully on `main`, configure main-branch protection (or an equivalent repository ruleset) to: + +1. Require a pull request before merging. +2. Require the `macos-core` and `macos-package-smoke` status checks and require them to be up to date. +3. Apply the rule to administrators as well as collaborators. +4. Do not allow force pushes or deletions of `main`. + +The workflow itself deliberately does not hold permission to change protection. That configuration is a repository-admin action and is verified separately in issue #81. + +## Fork and secret safety + +The workflow runs on `pull_request`, not `pull_request_target`, and declares no secrets. Every third-party action is pinned to a full commit SHA. Keep both properties. A future notarization or release workflow must run only from a protected environment after explicit human approval; it must not share credentials with this workflow. diff --git a/scripts/check_licenses.sh b/scripts/check_licenses.sh index 4fd514b..ac39c05 100755 --- a/scripts/check_licenses.sh +++ b/scripts/check_licenses.sh @@ -8,6 +8,8 @@ echo "Checking license policy guardrails..." FAILED=0 APACHE_2_0_SHA256="cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" +MATCH_FILE="$(mktemp "${TMPDIR:-/tmp}/opsmineflow_license_match.XXXXXX")" +trap 'rm -f "$MATCH_FILE"' EXIT if [[ ! -f LICENSE ]]; then echo "LICENSE file is missing." @@ -39,21 +41,21 @@ PROHIBITED_PACKAGES=( segment ) -while IFS= read -r manifest; do +while IFS= read -r -d '' manifest; do for package in "${PROHIBITED_PACKAGES[@]}"; do - if rg -n -i "(^|[\"' =_-])${package}([\"' <>=_-]|$)" "$manifest" >/tmp/opsmineflow_license_match.txt 2>/dev/null; then + if grep -E -n -i -- "(^|[\"' =_-])${package}([\"' <>=_-]|$)" "$manifest" >"$MATCH_FILE" 2>/dev/null; then echo "Prohibited dependency candidate '$package' found in $manifest" - cat /tmp/opsmineflow_license_match.txt + cat "$MATCH_FILE" FAILED=1 fi done done < <(find . -type f '(' -name 'package.json' -o -name 'pyproject.toml' -o -name 'requirements*.txt' -o -name 'Cargo.toml' ')' \ - -not -path './node_modules/*' \ - -not -path './.venv/*' \ - -not -path './venv/*' \ - -not -path './apps/desktop/src-tauri/target/*' | sort) + -not -path '*/node_modules/*' \ + -not -path '*/.venv/*' \ + -not -path '*/venv/*' \ + -not -path './apps/desktop/src-tauri/target/*' -print0) -if ! rg -n "Apache-2.0" LICENSE README.md README.ja.md docs/licenses >/dev/null; then +if ! grep -R -n -I -- "Apache-2.0" LICENSE README.md README.ja.md docs/licenses >/dev/null; then echo "Apache-2.0 license declaration was not found." FAILED=1 fi diff --git a/scripts/check_no_external_network.sh b/scripts/check_no_external_network.sh index 25e9afa..b5bf9bb 100755 --- a/scripts/check_no_external_network.sh +++ b/scripts/check_no_external_network.sh @@ -19,19 +19,31 @@ is_scan_metadata() { || "$file" == *"package-lock.json" ]] } -while IFS= read -r match; do - file="${match%%:*}" - rest="${match#*:}" - value="${rest#*:}" - if [[ "$file" == "scripts/check_no_external_network.sh" || "$file" == "scripts/check_licenses.sh" || "$file" == "scripts/bootstrap_mac.sh" ]] || is_scan_metadata "$file"; then - continue - fi - if [[ "$value" =~ ^https?://(127\.0\.0\.1|localhost)([:/].*)?$ ]]; then - continue - fi - echo "External URL candidate: $file -> $value" - FAILED=1 -done < <(rg -n -o "$URL_PATTERN" "${SCAN_PATHS[@]}" 2>/dev/null || true) +source_files() { + find "$@" -type f \ + -not -path '*/node_modules/*' \ + -not -path '*/.venv/*' \ + -not -path '*/venv/*' \ + -not -path '*/.pytest_cache/*' \ + -not -path '*/dist/*' \ + -not -path '*/src-tauri/target/*' \ + -not -path '*/src-tauri/gen/*' \ + -not -path '*/src-tauri/icons/*' -print0 +} + +while IFS= read -r -d '' file; do + while IFS= read -r match; do + value="${match#*:}" + if [[ "$file" == "scripts/check_no_external_network.sh" || "$file" == "scripts/check_licenses.sh" || "$file" == "scripts/bootstrap_mac.sh" ]] || is_scan_metadata "$file"; then + continue + fi + if [[ "$value" =~ ^https?://(127\.0\.0\.1|localhost)([:/].*)?$ ]]; then + continue + fi + echo "External URL candidate: $file -> $value" + FAILED=1 + done < <(grep -E -n -I -o -- "$URL_PATTERN" "$file" 2>/dev/null || true) +done < <(source_files "${SCAN_PATHS[@]}") PROHIBITED_TERMS=( telemetry @@ -49,20 +61,23 @@ PROHIBITED_TERMS=( ) for term in "${PROHIBITED_TERMS[@]}"; do - while IFS= read -r match; do - file="${match%%:*}" - if [[ "$file" == "scripts/check_no_external_network.sh" || "$file" == "scripts/check_licenses.sh" || "$file" == "scripts/bootstrap_mac.sh" ]] || is_scan_metadata "$file"; then - continue - fi - echo "Prohibited integration term '$term' found in $match" - FAILED=1 - done < <(rg -n -i "$term" apps services packages scripts 2>/dev/null || true) + while IFS= read -r -d '' file; do + while IFS= read -r match; do + if [[ "$file" == "scripts/check_no_external_network.sh" || "$file" == "scripts/check_licenses.sh" || "$file" == "scripts/bootstrap_mac.sh" ]] || is_scan_metadata "$file"; then + continue + fi + echo "Prohibited integration term '$term' found in $file:$match" + FAILED=1 + done < <(grep -E -n -I -i -- "$term" "$file" 2>/dev/null || true) + done < <(source_files "${SCAN_PATHS[@]}") done -if rg -n '"dangerousRemoteDomainIpcAccess"|externalBin|allowlist.*all|0\.0\.0\.0' apps packages services 2>/dev/null; then - echo "Potential unsafe network or Tauri configuration found." - FAILED=1 -fi +while IFS= read -r -d '' file; do + if grep -E -n -I -- '"dangerousRemoteDomainIpcAccess"|externalBin|allowlist.*all|0\.0\.0\.0' "$file" 2>/dev/null; then + echo "Potential unsafe network or Tauri configuration found in $file." + FAILED=1 + fi +done < <(source_files apps packages services) if [[ "$FAILED" -ne 0 ]]; then echo "Local-only network policy check failed." diff --git a/services/local-api/tests/test_api_logic.py b/services/local-api/tests/test_api_logic.py index 4cd40ec..00e3fac 100644 --- a/services/local-api/tests/test_api_logic.py +++ b/services/local-api/tests/test_api_logic.py @@ -294,8 +294,16 @@ def test_diagnostics_exposes_storage_and_local_only_policy(self) -> None: def test_diagnostic_checks_run_local_guardrails(self) -> None: results = run_diagnostic_checks() - self.assertEqual(results["license_policy"]["status"], "passed") - self.assertEqual(results["local_network_policy"]["status"], "passed") + self.assertEqual( + results["license_policy"]["status"], + "passed", + results["license_policy"].get("output", ""), + ) + self.assertEqual( + results["local_network_policy"]["status"], + "passed", + results["local_network_policy"].get("output", ""), + ) def test_import_preview_and_store_import_history(self) -> None: preview = create_import_preview("csv", "data/sample/sample_events.csv")