diff --git a/.github/scripts/feature_flag_check.py b/.github/scripts/feature_flag_check.py deleted file mode 100644 index 3aeeb36..0000000 --- a/.github/scripts/feature_flag_check.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and exercise the repository-owned feature flag contract.""" - -from __future__ import annotations - -import argparse -import datetime as dt -import os -import re -import subprocess -import sys -import tomllib -from pathlib import Path -from typing import Any - - -MANIFEST_PATH = Path(".airis/flags.toml") -TEMPORARY_KINDS = {"release", "experiment"} -VALID_KINDS = TEMPORARY_KINDS | {"ops", "permission", "kill_switch"} -VALID_TYPES = {"boolean", "string", "number", "json"} -KEY_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") - - -def fail(message: str) -> None: - print(f"::error::{message}", file=sys.stderr) - raise ValueError(message) - - -def require_string(flag: dict[str, Any], name: str, key: str) -> str: - value = flag.get(name) - if not isinstance(value, str) or not value.strip(): - fail(f"{key}: {name} must be a non-empty string") - return value - - -def parse_date(value: str, key: str) -> dt.date: - try: - return dt.date.fromisoformat(value) - except ValueError: - fail(f"{key}: expires must use YYYY-MM-DD") - raise AssertionError("unreachable") - - -def parse_test_case(value: Any, name: str, key: str) -> tuple[str, dict[str, str]]: - if not isinstance(value, dict): - fail(f"{key}: tests.{name} must be a table") - command = value.get("command") - if not isinstance(command, str) or not command.strip(): - fail(f"{key}: tests.{name}.command must be a non-empty string") - environment = value.get("environment", {}) - if not isinstance(environment, dict) or not all( - isinstance(env_key, str) and isinstance(env_value, str) - for env_key, env_value in environment.items() - ): - fail(f"{key}: tests.{name}.environment must map strings to strings") - return command, environment - - -def validate_flag(flag: Any, today: dt.date) -> tuple[str, list[tuple[str, dict[str, str]]]]: - if not isinstance(flag, dict): - fail("Each [[flags]] entry must be a table") - key = require_string(flag, "key", "") - if not KEY_PATTERN.fullmatch(key): - fail(f"{key}: invalid key") - kind = require_string(flag, "kind", key) - if kind not in VALID_KINDS: - fail(f"{key}: invalid kind {kind}") - flag_type = require_string(flag, "type", key) - if flag_type not in VALID_TYPES: - fail(f"{key}: invalid type {flag_type}") - require_string(flag, "owner", key) - - if kind not in TEMPORARY_KINDS: - return key, [] - - expires = parse_date(require_string(flag, "expires", key), key) - if expires < today: - fail(f"{key}: expired on {expires.isoformat()}") - require_string(flag, "cleanup_issue", key) - tests = flag.get("tests") - if not isinstance(tests, dict): - fail(f"{key}: temporary flags require [flags.tests.off] and [flags.tests.on]") - return key, [ - parse_test_case(tests.get("off"), "off", key), - parse_test_case(tests.get("on"), "on", key), - ] - - -def check(root: Path, today: dt.date) -> None: - manifest = root / MANIFEST_PATH - if not manifest.exists(): - print(f"Feature flag check skipped: {MANIFEST_PATH} is not present.") - return - try: - data = tomllib.loads(manifest.read_text()) - except tomllib.TOMLDecodeError as error: - fail(f"{MANIFEST_PATH}: invalid TOML: {error}") - flags = data.get("flags", []) - if not isinstance(flags, list): - fail(f"{MANIFEST_PATH}: flags must be an array of tables") - seen: set[str] = set() - test_cases: list[tuple[str, str, dict[str, str]]] = [] - for flag in flags: - key, cases = validate_flag(flag, today) - if key in seen: - fail(f"{key}: duplicate key") - seen.add(key) - test_cases.extend((key, command, environment) for command, environment in cases) - for key, command, environment in test_cases: - print(f"Running declared feature flag test for {key}") - result = subprocess.run( - ["bash", "-eo", "pipefail", "-c", command], - cwd=root, - env=os.environ | environment, - check=False, - ) - if result.returncode: - fail(f"{key}: declared off/on test failed ({result.returncode})") - print(f"Feature flag check passed: {len(flags)} definitions validated.") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--root", type=Path, default=Path.cwd()) - parser.add_argument("--today", type=dt.date.fromisoformat, default=dt.date.today()) - args = parser.parse_args() - try: - check(args.root.resolve(), args.today) - except ValueError: - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml deleted file mode 100644 index ef7ccf4..0000000 --- a/.github/workflows/bun-ci.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Bun CI - -on: - workflow_call: - inputs: - runs-on: - description: Runner label - type: string - default: ubuntu-latest - bun-version: - type: string - default: latest - test-command: - description: Command used for the mandatory test gate - type: string - default: bun test - -jobs: - ci: - name: ci - runs-on: ${{ inputs.runs-on }} - container: - image: node:26-bookworm - timeout-minutes: 20 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.0.2 - with: - bun-version: ${{ inputs.bun-version }} - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Test - run: ${{ inputs.test-command }} diff --git a/.github/workflows/docker-ghcr-publish.yml b/.github/workflows/docker-ghcr-publish.yml deleted file mode 100644 index dcf2da1..0000000 --- a/.github/workflows/docker-ghcr-publish.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Docker Publish - -# Reusable: build a multi-platform image and push to a registry (GHCR or in-cluster Zot). -# (Filename kept as docker-ghcr-publish.yml for backward-compat with existing callers.) -# jobs: -# publish: -# uses: agiletec-inc/.github/.github/workflows/docker-ghcr-publish.yml@ -# with: -# image-name: my-image -# registry: ghcr.io # or zot.zot.svc.cluster.local:5000 -# runs-on: agiletec-self-hosted-runner # required for in-cluster Zot -# secrets: -# registry-username: ${{ secrets.ZOT_USER }} # omit for GHCR -# registry-password: ${{ secrets.ZOT_PASSWORD }} # omit for GHCR -# -# GHCR uses GITHUB_TOKEN automatically (no secrets needed). On a pull_request the -# image is built but never pushed (dry-run gate). -on: - workflow_call: - inputs: - image-name: - description: Image name without registry/owner prefix - type: string - required: true - registry: - description: Target registry (ghcr.io or in-cluster Zot host:port) - type: string - default: ghcr.io - dockerfile: - type: string - default: Dockerfile - context: - type: string - default: . - platforms: - type: string - default: linux/amd64,linux/arm64 - push: - description: Push image (also gated off automatically on pull_request) - type: boolean - default: true - runs-on: - type: string - default: ubuntu-latest - secrets: - registry-username: - required: false - registry-password: - required: false - -jobs: - build-and-push: - name: docker-publish - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 30 - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - if: inputs.push && github.event_name != 'pull_request' - with: - registry: ${{ inputs.registry }} - username: ${{ inputs.registry == 'ghcr.io' && github.actor || secrets.registry-username }} - password: ${{ inputs.registry == 'ghcr.io' && secrets.GITHUB_TOKEN || secrets.registry-password }} - - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ inputs.registry }}/${{ github.repository_owner }}/${{ inputs.image-name }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix= - type=raw,value=latest,enable={{is_default_branch}} - - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: ${{ inputs.context }} - file: ${{ inputs.dockerfile }} - push: ${{ inputs.push && github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: ${{ inputs.platforms }} diff --git a/.github/workflows/docs-drift-warning.yml b/.github/workflows/docs-drift-warning.yml deleted file mode 100644 index ec8a2ce..0000000 --- a/.github/workflows/docs-drift-warning.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Documentation drift warning - -# Advisory documentation scan for consuming repositories. A caller supplies -# `.github/docs-drift-patterns.txt`; a match emits a warning but never fails CI. -on: - workflow_call: - inputs: - patterns-file: - description: Repository-relative file containing one regex per line - type: string - default: .github/docs-drift-patterns.txt - runs-on: - description: Runner label for the advisory job - type: string - default: ubuntu-latest - container-image: - description: Optional job container for self-hosted runners - type: string - default: '' - -jobs: - docs-drift: - name: docs-drift-warning - runs-on: ${{ inputs.runs-on }} - container: ${{ inputs.container-image }} - timeout-minutes: 5 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - - name: Scan documentation for configured retired terms - env: - PATTERNS_FILE: ${{ inputs.patterns-file }} - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - python3 - <<'PY' - import os - import re - import subprocess - from pathlib import Path - - patterns_file = Path(os.environ["PATTERNS_FILE"]) - if not patterns_file.is_file(): - print(f"::notice::No documentation drift patterns configured: {patterns_file}") - raise SystemExit(0) - - patterns = [] - for raw in patterns_file.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - try: - patterns.append((line, re.compile(line, re.IGNORECASE))) - except re.error as error: - print(f"::warning file={patterns_file}::Invalid documentation drift regex {line!r}: {error}") - - tracked = subprocess.check_output(["git", "ls-files"], text=True).splitlines() - docs = [ - Path(name) - for name in tracked - if Path(name).name.lower() in {"readme.md", "agents.md", "claude.md"} - or (Path(name).parts and Path(name).parts[0] == "docs" and name.endswith(".md")) - ] - - matches = 0 - for path in docs: - text = path.read_text(encoding="utf-8", errors="replace") - for line_number, line in enumerate(text.splitlines(), start=1): - for source, pattern in patterns: - if pattern.search(line): - print( - f"::warning file={path},line={line_number}::" - f"Documentation may be stale; matched configured term {source!r}" - ) - matches += 1 - - if matches: - print(f"Documentation drift scan found {matches} advisory match(es); CI remains green.") - else: - print("Documentation drift scan found no configured advisory matches.") - PY diff --git a/.github/workflows/feature-flag-check.yml b/.github/workflows/feature-flag-check.yml deleted file mode 100644 index 8962bb1..0000000 --- a/.github/workflows/feature-flag-check.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Feature flag check - -on: - workflow_call: - inputs: - runs-on: - description: Runner label - type: string - default: ubuntu-latest - -jobs: - check: - name: check - runs-on: ${{ inputs.runs-on }} - container: - image: python:3.12-bookworm - timeout-minutes: 20 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Check out quality gate implementation - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: agiletec-inc/.github - ref: main - path: .org-quality-gate - - name: Validate feature flags - run: python .org-quality-gate/.github/scripts/feature_flag_check.py --root "$GITHUB_WORKSPACE" diff --git a/.github/workflows/node-pnpm-ci.yml b/.github/workflows/node-pnpm-ci.yml deleted file mode 100644 index cc2b1ac..0000000 --- a/.github/workflows/node-pnpm-ci.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Node.js CI - -# Reusable, opinionated baseline for any Node package manager (npm / pnpm / yarn, -# auto-detected by lockfile). Runs the mandatory quality gates by default — a caller -# gets lint + typecheck + test + build + audit with ZERO config: -# -# jobs: -# ci: -# uses: agiletec-inc/.github/.github/workflows/node-pnpm-ci.yml@ -# # (optionally) with: { runs-on: agiletec-self-hosted-runner } -# -# Each gate runs the repo's npm script of the same name (` run lint` etc.); a -# missing mandatory script fails the job on purpose (the baseline expects it to exist). -# Turn a gate off with e.g. `with: { typecheck: false }` for the rare legitimate case. -# `run-command` (legacy) overrides the whole baseline when set. -# -# Resulting status-check name (for org ruleset): "ci / ci" -on: - workflow_call: - inputs: - node-version: - type: string - default: '22' - package-manager: - description: auto (detect by lockfile) | npm | pnpm | yarn - type: string - default: auto - lint: - type: boolean - default: true - typecheck: - type: boolean - default: true - test: - type: boolean - default: true - build: - type: boolean - default: true - audit: - description: Run ` audit --audit-level=high` - type: boolean - default: true - run-command: - description: Legacy override — when set, runs this INSTEAD of the baseline gates - type: string - default: '' - frozen-lockfile: - type: boolean - default: true - timeout-minutes: - type: number - default: 15 - runs-on: - type: string - default: ubuntu-latest - -jobs: - ci: - name: ci - runs-on: ${{ inputs.runs-on }} - container: - image: node:${{ inputs.node-version }}-bookworm - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - defaults: - run: - shell: bash - env: - PM: '' # set by Detect step - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Override commands may compare against an arbitrary PR base SHA. - # A fixed shallow depth loses that commit as main advances. - fetch-depth: 0 - - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 - # A repo's aggregate check script may shell out to Python sub-projects - # managed with uv (e.g. agiletec's `check:reach`); the node container - # image does not ship it. - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - - name: Allow Git to operate on the workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Detect package manager - id: pm - env: - PM_INPUT: ${{ inputs.package-manager }} - run: | - set -euo pipefail - pm="$PM_INPUT" - if [ "$pm" = auto ]; then - if [ -f pnpm-lock.yaml ]; then pm=pnpm - elif [ -f yarn.lock ]; then pm=yarn - else pm=npm - fi - fi - echo "pm=$pm" >> "$GITHUB_OUTPUT" - echo "PM=$pm" >> "$GITHUB_ENV" - echo "Package manager: $pm" - - if: steps.pm.outputs.pm == 'pnpm' - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - name: Install dependencies - env: - FROZEN: ${{ inputs.frozen-lockfile }} - PNPM_FETCH_RETRIES: "5" - run: | - set -euo pipefail - frozen="" - [ "$FROZEN" = true ] && frozen="--frozen-lockfile" - case "$PM" in - pnpm) pnpm install $frozen ;; - yarn) yarn install $frozen ;; - npm) if [ "$FROZEN" = true ]; then npm ci; else npm install; fi ;; - esac - - # ---- Legacy override: run a single command instead of the baseline ---- - - name: Run override command - if: inputs.run-command != '' - run: ${{ inputs.run-command }} - - # ---- Opinionated baseline gates (skipped when run-command is set) ---- - - name: Lint - if: inputs.run-command == '' && inputs.lint - run: $PM run lint - - name: Typecheck - if: inputs.run-command == '' && inputs.typecheck - run: $PM run typecheck - - name: Test - if: inputs.run-command == '' && inputs.test - run: $PM test - - name: Build - if: inputs.run-command == '' && inputs.build - run: $PM run build - - name: Audit - if: inputs.run-command == '' && inputs.audit - run: $PM audit --audit-level=high diff --git a/.github/workflows/org-quality-gate.yml b/.github/workflows/org-quality-gate.yml deleted file mode 100644 index 2b657a6..0000000 --- a/.github/workflows/org-quality-gate.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Organization quality gate - -# This workflow is selected by the organization ruleset. Do not add path -# filters: a required workflow must report for every pull request and merge -# queue entry. -on: - pull_request: - merge_group: - -permissions: - contents: read - -jobs: - quality-gate: - name: quality-gate - uses: agiletec-inc/.github/.github/workflows/quality-gate.yml@main - with: - linux-runs-on: ${{ github.event.repository.private && 'org-shared-ci-light' || 'ubuntu-latest' }} - swift-runs-on: macos-latest diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml deleted file mode 100644 index 0277eca..0000000 --- a/.github/workflows/python-ci.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: Python CI - -# Reusable: ruff + pytest (+ optional pip-audit / bandit) for Python repos. -# Handles both uv and pip projects: `tool: auto` picks uv when uv.lock exists. -# jobs: -# ci: -# uses: agiletec-inc/.github/.github/workflows/python-ci.yml@ -# with: -# runs-on: agiletec-self-hosted-runner # private; omit for public -# tool: auto # auto | uv | pip -# -# Resulting status-check name (for org ruleset): "ci / ci" -on: - workflow_call: - inputs: - runs-on: - description: Runner label - type: string - default: ubuntu-latest - python-version: - description: Python version - type: string - default: '3.12' - tool: - description: Package manager — auto (uv.lock present => uv, else pip) | uv | pip - type: string - default: auto - working-directory: - description: Repository-relative directory containing the Python project - type: string - default: . - install-command: - description: Override install step (empty => derived from tool) - type: string - default: '' - test-command: - description: Override lint+test step (empty => derived from tool) - type: string - default: '' - cov-fail-under: - description: Coverage ratchet — pytest --cov-fail-under= (empty => no coverage gate) - type: string - default: '' - timeout-minutes: - type: number - default: 20 - security-checks: - description: Run pip-audit + bandit as gates - type: boolean - default: true - -jobs: - ci: - name: ci - runs-on: ${{ inputs.runs-on }} - container: - image: python:${{ inputs.python-version }}-bookworm - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ${{ inputs.working-directory }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Resolve tool - id: tool - run: | - set -euo pipefail - tool='${{ inputs.tool }}' - if [ "$tool" = "auto" ]; then - if [ -f uv.lock ]; then tool=uv; else tool=pip; fi - fi - echo "tool=$tool" >> "$GITHUB_OUTPUT" - echo "Resolved package manager: $tool" - - name: Verify Python runtime - run: | - set -euo pipefail - python --version - python - <<'PY' - import os - import sys - - expected = os.environ['EXPECTED_PYTHON'].split('.')[:2] - actual = [str(sys.version_info.major), str(sys.version_info.minor)] - if actual != expected: - raise SystemExit(f'Python {".".join(actual)} != requested {".".join(expected)}') - PY - env: - EXPECTED_PYTHON: ${{ inputs.python-version }} - - name: Setup uv - if: steps.tool.outputs.tool == 'uv' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - with: - enable-cache: true - - name: Install - env: - TOOL: ${{ steps.tool.outputs.tool }} - OVERRIDE: ${{ inputs.install-command }} - run: | - set -euo pipefail - if [ -n "$OVERRIDE" ]; then - eval "$OVERRIDE" - elif [ "$TOOL" = "uv" ]; then - uv sync --all-extras --dev - else - python -m pip install --upgrade pip - if [ -f requirements-dev.txt ]; then python -m pip install -r requirements-dev.txt; fi - if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi - python -m pip install -e '.[dev]' || python -m pip install -e . || true - python -m pip install ruff pytest - fi - - name: Lint + Test - env: - TOOL: ${{ steps.tool.outputs.tool }} - OVERRIDE: ${{ inputs.test-command }} - COV: ${{ inputs.cov-fail-under }} - run: | - set -euo pipefail - pytest_args=() - if [ -n "$COV" ]; then - pytest_args=(--cov --cov-report=term-missing --cov-fail-under="$COV") - fi - if [ -n "$OVERRIDE" ]; then - eval "$OVERRIDE" - elif [ "$TOOL" = "uv" ]; then - uv run ruff check . - uv run ruff format --check . - uv run pytest "${pytest_args[@]}" - else - ruff check . - ruff format --check . - pytest "${pytest_args[@]}" - fi - - name: Security checks - if: inputs.security-checks - env: - TOOL: ${{ steps.tool.outputs.tool }} - run: | - set -euo pipefail - if [ "$TOOL" = "uv" ]; then - uvx pip-audit || exit 1 - uvx bandit -r . -q -x '*/tests/*,./.venv/*,*/.venv/*' || exit 1 - else - python -m pip install pip-audit bandit - pip-audit || exit 1 - bandit -r . -q -x '*/tests/*,./.venv/*,*/.venv/*' || exit 1 - fi diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml deleted file mode 100644 index 30a33e2..0000000 --- a/.github/workflows/quality-gate.yml +++ /dev/null @@ -1,209 +0,0 @@ -name: Organization quality gate - -# Reusable organization-wide gate. The caller workflow must always start on -# pull_request and merge_group; use job-level conditions here, never workflow -# path filters, so required checks are reported for every pull request. -on: - workflow_call: - inputs: - linux-runs-on: - description: Linux runner label for detection and Linux quality jobs - type: string - default: ubuntu-latest - swift-runs-on: - description: macOS runner label for Swift quality jobs - type: string - default: macos-latest - -permissions: - contents: read - -jobs: - detect: - name: detect - runs-on: ${{ inputs.linux-runs-on }} - container: - image: node:26-bookworm - outputs: - node: ${{ steps.detect.outputs.node }} - node_version: ${{ steps.detect.outputs.node_version }} - node_run_command: ${{ steps.detect.outputs.node_run_command }} - bun: ${{ steps.detect.outputs.bun }} - python: ${{ steps.detect.outputs.python }} - python_directory: ${{ steps.detect.outputs.python_directory }} - rust: ${{ steps.detect.outputs.rust }} - swift: ${{ steps.detect.outputs.swift }} - swift_directory: ${{ steps.detect.outputs.swift_directory }} - supported: ${{ steps.detect.outputs.supported }} - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - id: detect - name: Detect repository stacks - shell: bash - run: | - set -euo pipefail - - has_root_file() { - test -f "$1" - } - - node=false - node_version=22 - node_run_command='' - bun=false - python=false - python_directory=. - rust=false - swift=false - swift_directory=. - if has_root_file package.json; then - if has_root_file bun.lock || has_root_file bun.lockb; then - bun=true - else - node=true - node_version="$(node -e "const pkg=require('./package.json'); const match=(pkg.engines?.node ?? '').match(/(?:^|[^0-9])(\\d{2,})(?:\\D|$)/); console.log(match?.[1] ?? '22')")" - scripts="$(node -e "console.log(JSON.stringify(require('./package.json').scripts ?? {}))")" - if [ "$(node -e "const scripts=JSON.parse(process.argv[1]); console.log(Boolean(scripts.check) && !scripts.lint)" "$scripts")" = true ]; then - if has_root_file pnpm-lock.yaml; then - node_run_command='pnpm check' - elif has_root_file yarn.lock; then - node_run_command='yarn check' - else - node_run_command='npm run check' - fi - fi - fi - fi - if has_root_file pyproject.toml || has_root_file setup.py || has_root_file requirements.txt; then - python=true - elif [ -f apps/api/pyproject.toml ]; then - python=true - python_directory=apps/api - else - python_project="$(find . -maxdepth 3 \ - -path '*/.git' -prune -o \ - -path '*/.venv' -prune -o \ - -path '*/node_modules' -prune -o \ - -type f -name pyproject.toml \ - -print -quit)" - if [ -n "$python_project" ]; then - python=true - python_directory="$(dirname "$python_project")" - fi - fi - has_root_file Cargo.toml && rust=true - swift_project="$(find . -maxdepth 3 \ - -path '*/.git' -prune -o \ - -path '*/.build' -prune -o \ - -path '*/build' -prune -o \ - -type f -name Package.swift \ - -print -quit)" - if [ -n "$swift_project" ]; then - swift=true - swift_directory="$(dirname "$swift_project")" - fi - supported=false - if [ "$node" = true ] || [ "$bun" = true ] || [ "$python" = true ] || [ "$rust" = true ] || [ "$swift" = true ]; then - supported=true - fi - - { - echo "node=$node" - echo "node_version=$node_version" - echo "node_run_command=$node_run_command" - echo "bun=$bun" - echo "python=$python" - echo "python_directory=$python_directory" - echo "rust=$rust" - echo "swift=$swift" - echo "swift_directory=$swift_directory" - echo "supported=$supported" - } >> "$GITHUB_OUTPUT" - printf 'node=%s\nnode_version=%s\nnode_run_command=%s\nbun=%s\npython=%s\npython_directory=%s\nrust=%s\nswift=%s\nswift_directory=%s\nsupported=%s\n' "$node" "$node_version" "$node_run_command" "$bun" "$python" "$python_directory" "$rust" "$swift" "$swift_directory" "$supported" - - node-ci: - name: node-ci - needs: detect - if: ${{ needs.detect.outputs.node == 'true' }} - uses: ./.github/workflows/node-pnpm-ci.yml - with: - runs-on: ${{ inputs.linux-runs-on }} - node-version: ${{ needs.detect.outputs.node_version }} - run-command: ${{ needs.detect.outputs.node_run_command }} - - bun-ci: - name: bun-ci - needs: detect - if: ${{ needs.detect.outputs.bun == 'true' }} - uses: ./.github/workflows/bun-ci.yml - with: - runs-on: ${{ inputs.linux-runs-on }} - - python-ci: - name: python-ci - needs: detect - if: ${{ needs.detect.outputs.python == 'true' }} - uses: ./.github/workflows/python-ci.yml - with: - runs-on: ${{ inputs.linux-runs-on }} - tool: auto - working-directory: ${{ needs.detect.outputs.python_directory }} - - rust-ci: - name: rust-ci - needs: detect - if: ${{ needs.detect.outputs.rust == 'true' }} - uses: ./.github/workflows/rust-cargo-ci.yml - with: - runs-on: ${{ inputs.linux-runs-on }} - - swift-ci: - name: swift-ci - needs: detect - # Capability condition, not a repo special case: the Swift toolchain needs - # macOS, the org has no self-hosted macOS runner, and GitHub-hosted runners - # are blocked for private repos (zero spending limit) — the job would fail - # at assignment before running a single step. Public repos keep the gate. - if: ${{ needs.detect.outputs.swift == 'true' && !github.event.repository.private }} - uses: ./.github/workflows/swift-ci.yml - with: - runs-on: ${{ inputs.swift-runs-on }} - working-directory: ${{ needs.detect.outputs.swift_directory }} - - secret-scan: - name: secret-scan - uses: ./.github/workflows/secret-scan.yml - with: - runs-on: ${{ inputs.linux-runs-on }} - - feature-flag-check: - name: feature-flag-check - uses: ./.github/workflows/feature-flag-check.yml - with: - runs-on: ${{ inputs.linux-runs-on }} - - quality-gate: - name: quality-gate - needs: [detect, node-ci, bun-ci, python-ci, rust-ci, swift-ci, secret-scan, feature-flag-check] - if: ${{ always() }} - runs-on: ${{ inputs.linux-runs-on }} - container: - image: node:26-bookworm - steps: - - name: Reject failed or cancelled jobs - env: - NEEDS: ${{ toJSON(needs) }} - run: | - node <<'NODE' - const needs = JSON.parse(process.env.NEEDS); - const failed = Object.entries(needs) - .filter(([, result]) => result.result === 'failure' || result.result === 'cancelled') - .map(([name, result]) => `${name}=${result.result}`); - - if (failed.length > 0) { - console.error(`Quality gate failed: ${failed.join(', ')}`); - process.exit(1); - } - - console.log('Quality gate passed: all applicable jobs succeeded or were skipped.'); - NODE diff --git a/.github/workflows/rust-cargo-ci.yml b/.github/workflows/rust-cargo-ci.yml deleted file mode 100644 index 7ebc426..0000000 --- a/.github/workflows/rust-cargo-ci.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Rust Cargo CI - -# Reusable: cargo fmt / clippy / test / audit for Rust repos. -# jobs: -# ci: -# uses: agiletec-inc/.github/.github/workflows/rust-cargo-ci.yml@ -# with: -# runs-on: agiletec-self-hosted-runner # private; omit for public -# -# Resulting status-check name (for org ruleset): "ci / ci" -on: - workflow_call: - inputs: - runs-on: - description: Runner label - type: string - default: ubuntu-latest - toolchain: - description: Rust toolchain (stable / 1.xx / nightly) - type: string - default: stable - timeout-minutes: - description: Job timeout - type: number - default: 30 - clippy-args: - description: Extra args appended to cargo clippy (before -- -D warnings) - type: string - default: --all-targets --all-features - test-args: - description: Extra args appended to cargo test - type: string - default: --all-features - audit: - description: Run cargo-audit (RustSec) as a gate - type: boolean - default: true - package-size-check: - description: Run `cargo package` and fail if the .crate exceeds crates.io's 10MB limit - type: boolean - default: false - windows-check: - description: Add a windows-latest job running `cargo check` (catches Windows-only breakage) - type: boolean - default: false - -jobs: - ci: - name: ci - runs-on: ${{ inputs.runs-on }} - container: - image: node:26-bookworm - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (pinned) - with: - toolchain: ${{ inputs.toolchain }} - components: rustfmt, clippy - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: Format check - run: cargo fmt --all --check - - name: Clippy - run: cargo clippy ${{ inputs.clippy-args }} -- -D warnings - - name: Test - run: cargo test ${{ inputs.test-args }} - # cargo-audit binary (not rustsec/audit-check action): the action publishes via - # the Checks API, which needs checks:write and fails on private/forked repos - # ("Resource not accessible by integration"). Plain `cargo audit` exits non-zero - # on advisories — same gate, no extra permissions. - - name: Install cargo-audit - if: inputs.audit - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 - with: - tool: cargo-audit - - name: Audit - if: inputs.audit - run: cargo audit - - name: Package size check (crates.io 10MB limit) - if: inputs.package-size-check - run: | - set -euo pipefail - cargo package --no-verify --allow-dirty - crate=$(ls target/package/*.crate | head -1) - bytes=$(stat -c%s "$crate") - limit=10485760 - echo "Crate: $crate — $bytes bytes (limit $limit)" - if [ "$bytes" -gt "$limit" ]; then - echo "::error::Package exceeds crates.io 10MB limit ($bytes > $limit)" - exit 1 - fi - echo "OK: under limit" - - windows: - name: windows - if: inputs.windows-check - runs-on: windows-latest - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master (pinned) - with: - toolchain: ${{ inputs.toolchain }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: Cargo check - run: cargo check --all-targets diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml deleted file mode 100644 index 5ae030a..0000000 --- a/.github/workflows/secret-scan.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Secret Scan - -# Reusable: gitleaks secret detection via the pinned binary (no GITLEAKS_LICENSE -# required — the gitleaks-action needs a license for org accounts; the binary does not). -# Fails the job on any finding (real required-check, not a vacuous green). -# -# jobs: -# secret-scan: -# uses: agiletec-inc/.github/.github/workflows/secret-scan.yml@ -# with: -# runs-on: agiletec-self-hosted-runner # private repos; omit for public -# -# Resulting status-check name (for org ruleset "Require status checks"): "secret-scan / scan" -on: - workflow_call: - inputs: - runs-on: - description: Runner label (agiletec-self-hosted-runner for private, ubuntu-latest for public) - type: string - default: ubuntu-latest - gitleaks-version: - description: gitleaks release version (no leading v) - type: string - default: '8.30.1' - -jobs: - scan: - name: scan - runs-on: ${{ inputs.runs-on }} - container: - image: node:26-bookworm - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - name: Run gitleaks - shell: bash - env: - GITLEAKS_VERSION: ${{ inputs.gitleaks-version }} - run: | - set -euo pipefail - case "$(uname -m)" in - x86_64|amd64) arch=x64 ;; - aarch64|arm64) arch=arm64 ;; - *) echo "::error::unsupported arch $(uname -m)"; exit 1 ;; - esac - os=$(uname -s | tr '[:upper:]' '[:lower:]') - url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${os}_${arch}.tar.gz" - echo "Downloading ${url}" - curl -fsSL "${url}" | tar -xz gitleaks - ./gitleaks version - config_args=() - if [ -f .gitleaks.toml ]; then - config_args=(--config .gitleaks.toml) - echo "Using repository .gitleaks.toml configuration." - fi - case "$GITHUB_EVENT_NAME" in - pull_request) - ./gitleaks git "${config_args[@]}" --redact --no-banner --exit-code 1 --verbose \ - --log-opts "${{ github.event.pull_request.base.sha }}..HEAD" . - ;; - merge_group) - ./gitleaks git "${config_args[@]}" --redact --no-banner --exit-code 1 --verbose \ - --log-opts "${{ github.event.merge_group.base_sha }}..HEAD" . - ;; - push) - if [[ "${{ github.event.before }}" =~ ^0+$ ]]; then - ./gitleaks dir . "${config_args[@]}" --redact --no-banner --exit-code 1 --verbose - else - ./gitleaks git "${config_args[@]}" --redact --no-banner --exit-code 1 --verbose \ - --log-opts "${{ github.event.before }}..${{ github.sha }}" . - fi - ;; - *) - ./gitleaks dir . "${config_args[@]}" --redact --no-banner --exit-code 1 --verbose - ;; - esac diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml deleted file mode 100644 index 6e92241..0000000 --- a/.github/workflows/swift-ci.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Swift CI - -# Reusable: swift test + release build smoke for Swift Package Manager repos. -# Runs on GitHub-hosted macOS (public repos free; Swift toolchain needs macOS). -# jobs: -# ci: -# uses: agiletec-inc/.github/.github/workflows/swift-ci.yml@ -# -# Resulting status-check name (for org ruleset): "ci / ci" -on: - workflow_call: - inputs: - runs-on: - description: Runner label (macOS required for the Swift toolchain) - type: string - default: macos-latest - working-directory: - description: Subdirectory holding Package.swift (e.g. apps/cmd-ime-swift). Empty => repo root. - type: string - default: '' - timeout-minutes: - type: number - default: 30 - test: - description: Run swift test - type: boolean - default: true - release-build: - description: Run the release build smoke (uses release-command) - type: boolean - default: true - release-command: - description: Release build smoke command (default `swift build -c release`) - type: string - default: swift build -c release - -jobs: - ci: - name: ci - runs-on: ${{ inputs.runs-on }} - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - defaults: - run: - working-directory: ${{ inputs.working-directory || '.' }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Swift version - run: swift --version - - name: Test - if: inputs.test - run: swift test - - name: Release build smoke - if: inputs.release-build - run: ${{ inputs.release-command }} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index b35c402..76c332b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,165 +1,25 @@ -# Contributor Covenant Code of Conduct +# 行動規範 -## Our Pledge +Agiletecのprojectでは、背景、経験、属性にかかわらず、誰もが安全に参加できる環境を維持します。 -The Agiletec Inc. community pledges to provide a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual orientation. +## 期待する行動 -**Our Mission**: Eliminate multi-tier subcontracting structures and empower all companies with in-house development capabilities. +- 相手と異なる意見・経験を尊重する。 +- 具体的で建設的なfeedbackを行い、誤りを認めて修正する。 +- 個人情報、security情報、非公開情報を本人の同意なく公開しない。 +- projectとcommunity全体への影響を考えて行動する。 ---- +## 認めない行動 -## Our Standards +- 差別、威圧、嫌がらせ、性的な言動。 +- trolling、侮辱、個人攻撃、継続的な妨害。 +- 他者の個人情報や非公開情報の無断公開。 +- project空間の安全を損なうその他の行為。 -### ✅ Positive Behavior +## 適用範囲と執行 -**Welcomed behaviors**: -- Respectful and empathetic language toward others -- Respect for different opinions and experiences -- Accepting and providing constructive feedback gracefully -- Prioritizing the community's collective benefit -- Pursuing technical excellence and integrity +この規範はrepository、Issue、Pull Request、Discussionなどのproject空間と、projectを代表して参加する場に +適用します。maintainerは違反する投稿や変更の編集・削除、参加制限など、影響に応じた対応を行えます。 -**Values we cherish**: -- **Transparency**: Open and honest communication -- **Empowerment**: Support each other's growth -- **Craftsmanship**: Respect technical skills and quality -- **Collaboration**: Co-creation and mutual support - ---- - -### ❌ Unacceptable Behavior - -**Prohibited behaviors**: -- Use of sexualized language or imagery -- Trolling, insulting comments, and personal attacks -- Public or private harassment -- Publishing others' private information without permission -- Conduct deemed unprofessional - ---- - -## Our Responsibilities - -### Project Maintainers - -Maintainers are responsible for clarifying standards of this code of conduct and taking appropriate action in response to violations. - -**Authority**: -- Remove, edit, or reject comments, commits, code, issues, and other contributions that are inappropriate -- Temporarily or permanently exclude contributors from the community - ---- - -## Scope - -This code of conduct applies to: -- Project spaces (GitHub, Discussions, etc.) -- Public spaces when representing the project -- Online and offline community events - ---- - -## Enforcement - -### How to Report - -If you witness harassment or inappropriate behavior: - -📧 **conduct@agiletec.net** - -**Report Contents**: -- Date, time, and location of incident -- Names of individuals involved -- Details of the behavior -- Evidence (screenshots, etc.) - -All reports will be treated as confidential. - ---- - -### Response Process - -1. **Acknowledgment** (within 24 hours) - - Confirm receipt of report - -2. **Investigation** (within 3 business days) - - Assess severity of violation (CVSS v3.1) - - Investigate scope of impact - -3. **Decision** - - Determine appropriate measures after fact-finding - -4. **Notification** - - Notify reporter and involved parties of results - ---- - -### Types of Measures - -**Minor Violations**: -- Verbal or written warning -- Temporary communication restrictions - -**Moderate Violations**: -- Temporary activity suspension (1 week to 1 month) -- Temporary exclusion from specific projects - -**Severe Violations**: -- Permanent exclusion from the community -- Legal action (if necessary) - ---- - -## Philosophy - -Our community aims to balance **technical excellence** with **respect for humanity**. - -###融合 of Technology and Humanity - -- **Honest Feedback**: Candid feedback fuels growth (but with respect) -- **Disagree and Commit**: Differences of opinion are welcome, but unite after decisions -- **Assume Good Intent**: Assume good intentions, not malice -- **High Standards, High Support**: Balance high standards with strong support - ---- - -## Examples - -### ✅ Good Communication - -``` -"I have concerns about this approach. -Reason 1: Performance issue with XX -Reason 2: Security risk of YY -I propose ZZ as an alternative." -``` - -### ❌ Bad Communication - -``` -"This code is terrible. -Are you a beginner writing stuff like this?" -``` - ---- - -## Attribution - -This code of conduct is based on [Contributor Covenant](https://www.contributor-covenant.org/) version 2.1. - -Change history available [here](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). - ---- - -## Contact - -For questions or concerns: - -- **Email**: conduct@agiletec.net -- **GitHub Discussions**: Each project's Discussions - ---- - -**We aim for a community where everyone can contribute safely and productively.** - -— Agiletec Inc. Team +問題は公開Issueへ書かず、`security@agiletec.net`へ報告してください。報告者と関係者のprivacyを必要な範囲で +保護します。 diff --git a/SECURITY.md b/SECURITY.md index 4deadf2..98dea8f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,200 +1,23 @@ -# Security Policy +# Security policy -## 🛡️ Agiletec Inc. Security Commitment +security vulnerabilityを通常の公開Issueへ投稿しないでください。 -We prioritize the security of our users and community above all else. +## 報告方法 -**Philosophy**: Transparency and responsible disclosure +対象repositoryでprivate vulnerability reportingが利用できる場合は、GitHub Security Advisoryから報告してください。 +利用できない場合は`security@agiletec.net`へ送ってください。 ---- +報告には次を含めてください。 -## 🔍 Supported Versions +- 影響するrepository、機能、revisionまたはversion +- 再現条件と最小の手順 +- 想定される影響 +- 既に行った検証と、可能なら修正案 -Versions receiving security updates: +credential、個人情報、実利用者のデータは必要以上に添付しないでください。 -| Project | Version | Supported | -|---------|---------|-----------| -| AIRIS MCP Gateway | 1.x.x | ✅ | -| mindbase | 0.x.x (Beta) | ✅ | -| superagent | 1.x.x | ✅ | -| neural | 0.x.x (Beta) | ✅ | -| selfhosted-supabase-mcp | 1.x.x | ✅ | -| cmd-ime | 0.x.x (Beta) | ✅ | +## 対応と公開 ---- - -## 🚨 Reporting a Vulnerability - -### How to Report - -**Important**: Do not report security vulnerabilities through public issues. - -**Contact**: -- 📧 **Email**: security@agiletec.net -- 🔒 **Encryption**: PGP Key available on request - -**Report Contents**: -1. Detailed description of the vulnerability -2. Affected versions -3. Reproduction steps (PoC) -4. Potential impact scope -5. Proposed fix (if available) - ---- - -### Response Process - -**Timeline**: - -1. **Acknowledgment** (within 24 hours) - - Confirm receipt of report and send acknowledgment - -2. **Initial Assessment** (within 3 business days) - - Evaluate vulnerability severity (CVSS v3.1) - - Investigate impact scope - -3. **Fix Development** (based on severity) - - Critical: within 1 week - - High: within 2 weeks - - Medium: within 1 month - - Low: next release - -4. **Patch Release** - - Release security patch - - Obtain CVE number (if necessary) - -5. **Disclosure** (90 days after patch release) - - Publish vulnerability details - - Acknowledge reporter - ---- - -## 🏆 Vulnerability Rewards - -### Bounty Program - -**Target Projects**: -- AIRIS MCP Gateway -- mindbase -- superagent -- neural -- selfhosted-supabase-mcp -- cmd-ime -- Agiletec Platform (private products) - -**Bounty Amounts** (under consideration): -- **Critical**: $500 - $2,000 -- **High**: $200 - $500 -- **Medium**: $50 - $200 -- **Low**: Acknowledgment only - -**Excluded**: -- Known vulnerabilities in third-party libraries -- Social engineering attacks -- Attacks requiring physical access -- DoS/DDoS attacks - ---- - -## 🔒 Security Best Practices - -### During Development - -**Secret Management**: -- ✅ Use secret managers like Infisical -- ❌ Don't commit `.env` files to Git -- ❌ Don't hardcode API keys in code - -**Dependency Management**: -- Run `npm audit` or `pnpm audit` regularly -- Enable Dependabot -- Regular library updates - -**Authentication & Authorization**: -- Set JWT token expiration -- Implement CSRF protection -- Introduce rate limiting - ---- - -### During Deployment - -**Infrastructure Security**: -- ✅ Enforce HTTPS/TLS -- ✅ Principle of Least Privilege -- ✅ Network isolation (Docker networks) -- ✅ Regular security patch application - -**Data Protection**: -- Database encryption (at-rest, in-transit) -- Row-Level Security (RLS) for multi-tenant isolation -- Backup encryption - ---- - -## 📊 Security Audit History - -| Date | Auditor | Scope | Findings | -|------|---------|-------|----------| -| TBD | Internal | AIRIS Platform | TBD | -| TBD | External | MCP Gateway | TBD | - ---- - -## 🔐 Compliance - -### Standards - -We strive to comply with the following security standards: - -- **OWASP Top 10** (Web Application Security) -- **CWE Top 25** (Common Weakness Enumeration) -- **NIST Cybersecurity Framework** (Infrastructure Security) - -### Data Protection - -- **GDPR Compliance** (for EU customers) -- **Japan Personal Information Protection Act Compliance** -- Data minimization principle - ---- - -## 📚 Security Resources - -### Learning Materials - -- [OWASP Top 10](https://owasp.org/www-project-top-ten/) -- [Supabase Security Best Practices](https://supabase.com/docs/guides/auth) -- [Docker Security](https://docs.docker.com/engine/security/) - -### Tools - -- **Static Analysis**: ESLint security plugins -- **Dependency Scanning**: Snyk, npm audit -- **Secret Scanning**: git-secrets, TruffleHog -- **Container Scanning**: Trivy - ---- - -## 🙏 Acknowledgments - -We thank security researchers: - -- (Researcher names - to be added after vulnerability disclosure) - ---- - -## 📞 Contact - -For general security questions: - -- 📧 **Email**: security@agiletec.net -- 🐙 **GitHub Security Advisory**: Security tab of each project - ---- - -**Security is a continuous journey, not a destination.** - -We strive daily to provide safe and reliable software. - -— Agiletec Inc. Security Team +受領後に影響と再現性を確認し、報告者と修正・公開時期を調整します。調査中の情報を、修正または緩和策が +利用可能になる前に公開しないでください。support対象versionと修正提供範囲は、各repositoryのrelease情報を +正本とします。 diff --git a/dependabot.yml b/dependabot.yml deleted file mode 100644 index e11b996..0000000 --- a/dependabot.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: 2 -updates: - # Bump SHA-pinned actions in the org reusable workflows. - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: Asia/Tokyo - open-pull-requests-limit: 5 - commit-message: - prefix: "ci" diff --git a/docs/feature-flag-quality-gate.md b/docs/feature-flag-quality-gate.md deleted file mode 100644 index 76c7bb3..0000000 --- a/docs/feature-flag-quality-gate.md +++ /dev/null @@ -1,29 +0,0 @@ -# Feature flag品質gate - -organizationの`quality-gate`は全repoでfeature flagを検証する。`.airis/flags.toml`がないrepoはこのcheckをskipして -成功する。flagを宣言するrepoがmetadataとoff/on test commandを所有する。 - -organization rulesetは`.github/workflows/org-quality-gate.yml`を必須化する。workflowは`pull_request`と -`merge_group`からreusable quality gateを呼び、caller側でpath filterを加えない。対応language manifestがない -repoでもsecret/flag gateは実行し、language固有jobだけをskipする。 - -```toml -[[flags]] -key = "checkout.v2" -kind = "release" -type = "boolean" -owner = "team:billing" -expires = "2026-12-31" -cleanup_issue = "https://github.com/agiletec-inc/example/issues/123" - -[flags.tests.off] -command = "pnpm test:checkout-v2-off" -environment = { CHECKOUT_V2 = "false" } - -[flags.tests.on] -command = "pnpm test:checkout-v2-on" -environment = { CHECKOUT_V2 = "true" } -``` - -`release`と`experiment`は一時flagなのでowner、未来の`expires`、cleanup Issue、off/on testを必須とする。 -`ops`、`permission`、`kill_switch`はownerを必須とするが、expiryとtest pairは必須にしない。 diff --git a/policies/ci-cd-trigger-strategy.md b/policies/ci-cd-trigger-strategy.md deleted file mode 100644 index 3f41edb..0000000 --- a/policies/ci-cd-trigger-strategy.md +++ /dev/null @@ -1,132 +0,0 @@ -# CI/CD Trigger Strategy - -Canonical policy for build / deploy triggers across all `agiletec-inc` repositories. - -This is the single source of truth. Repo-level `CLAUDE.md` files should -reference this document rather than restate the policy. Local -checkout-root notes (e.g. `agiletec-inc/CLAUDE.md` on a developer -machine) should link here, not duplicate. - -## Goals - -(2026-06-12 改定: 旧 release-driven stg deploy 標準と plan 520 は **supersede**。 -1人法人 + 自宅単一クラスタに対し、デプロイチェーンの段数そのものが最大の -故障source だったため「merge = stg deploy」に簡素化。) - -1. **`main` is always shippable.** Code lands via PR + Required checks. Direct push is banned by the Org Ruleset "Main Branch Protection". **CI required checks は品質ゲートとして不変** — ここがコーディングエージェント (Claude Code) の防波堤。 -2. **Stage deploy = `main` push 直デプロイ。** merge された瞬間に stg に出る。中間機械 (bump PR / 耐久マージャ / release tag) を挟まない。デプロイの実行体は GA workflow 1 本 + デプロイスクリプト 1 本で、スクリプトは手動実行の脱出ハッチを兼ねる。 -3. **Production deploy = 手動のみ。** `workflow_dispatch` + `environment: prd`(required reviewers)か、運用者の手作業。stg からの自動昇格はしない。 - - **例外: agiletec Supabase (migrations + Edge Functions) は main マージで CI 自動 deploy。** `deploy-supabase.yml` が `push:[main]`(`supabase/**` paths) で起動し、drift gate (`migration list --linked` で prd の REMOTE-only migration を検出して fail) → `db push` → `functions deploy` → post-deploy probe。承認ボタンには依存しない(Team-private では required reviewers が不発)。安全担保は PR ゲート (db reset from scratch + pgTAP + monotonic guard) + deploy 時 drift gate。agiletec の CF frontend promote は従来どおり手動。 -4. **ArgoCD はインフラ + Deployment 構造の reconcile 専任。** image の中身はデプロイレーンが直接届ける (固定 mutable タグ、git 外)。image tag churn を GitOps に流さない。 -5. **Release tag は public OSS の配布物にだけ使う**(cmd-ime の Homebrew 配布等)。デプロイのトリガーには使わない。 - -## Trigger map - -| Stage | Trigger | Effect | Failure isolation | -|---|---|---|---| -| CI (品質ゲート) | `on: pull_request` | lint / test / build。Required checks (`ci / ci`, `secret-scan / scan`) が merge をブロック | 詰まり = merge 不可で即可視。デプロイには波及しない | -| Stage deploy | `on: push: branches: [main]` (paths-filtered) + `workflow_dispatch` | デプロイスクリプト実行 (例: airis-studio = 既存 ARC runner で nerdctl direct-bake `:stg` → `kubectl rollout restart`。workflow pod に containerd socket を hostPath マウント) | 失敗は workflow run に出る。スクリプト手実行(サーバー上)で即復旧可 | -| Production deploy | `workflow_dispatch` + `environment: prd` (required reviewers) / 手動運用 | 運用者の明示アクションでのみ prd へ | Reviewer absent = no prod deploy | - -### Single-environment 運用 (個人ツール tier) - -airis-studio のような operator=利用者 のツールは **stg が本番**(別 prd を持たない)。 -namespace 分離・CI ゲート・自動デプロイはフル装備のまま、環境を 1 つに畳む。 - -### Release tag (public OSS 配布物のみ) - -`-vMAJOR.MINOR.PATCH[-suffix]`(例: `cmd-ime-v0.7.0`)。配布物 -(Homebrew cask、バイナリ)の公開トリガーであり、デプロイとは無関係。 - -## Org-wide enforcement model (CI / quality gates) - -CI/品質ゲートは **org ルールセット "Require status checks to pass"**(Team プランで利用可) -で強制する。各リポは薄い caller(`.github/workflows/ci.yml`)で `agiletec-inc/.github` の -reusable を呼ぶだけ。**ロジックの SSoT は reusable 側**(修正は 1 箇所)。 - -- **ターゲティング = custom property**(GitHub Well-Architected 推奨。repo 名リストでない)。 - org custom property **`ci_managed`**(true_false, GA 2026-01-13・Team 可)を定義し、ルールセット - "Org CI required checks"(id 17507867)を `props.ci_managed:true` フィルタで対象化。**リポは - property を立てるだけで enroll、ルールセット定義は不変**。 -- **必須チェック名**: `secret-scan / scan` と `ci / ci`(reusable 呼びのチェック名は - ` / ` で合成)。内部 job 名は `secret-scan.yml`→`scan` / - 言語 reusable→`ci` に固定済み(enforce 前に安定したチェック名を出す = Well-Architected 準拠)。 -- **caller 配布**: 新規リポは Actions タブの starter template(`.github/workflow-templates/`)から - 1 クリック。既存リポは **リポ毎のレビュー付き移行 PR**(PM/run-command を検証。盲目的な一括 - 置換はしない)。配布後にそのリポへ `ci_managed=true` を付与。 -- **runner ルーティング**: caller が private→`agiletec-self-hosted-runner` / public→hosted を指定。 -- **secret-scan**: gitleaks バイナリ直叩き(org でも `GITLEAKS_LICENSE` 不要)。漏洩で実際に - job を fail させる(`continue-on-error` による空虚な緑を排除)。 -- **ロールアウト**: evaluate→pilot→expand→enforce のうち **evaluate は Enterprise 限定**。Team は - **pilot(対象を 1 リポに絞った active)** で代替(airis-keeper で実証済)。 - -> **不採用**: org ルールセットの "Require workflows to pass before merging"(ゲートを各リポに -> 自動注入し caller ファイルを不要にする機能)は **GitHub Enterprise Cloud 限定**。org plan が -> `team` の間は使えない(設定画面に出ても実行されない)。Enterprise upgrade($4→$21/user)は -> 小規模 org に過剰なため、上記の status-check 強制で同等の効果を得る。差は「caller ファイルが -> 各リポに要るか否か」だけ。 -> 参照: [Available rules for rulesets](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets) - -## Visibility model - -The trigger model is the same for private and public repos. What -differs is which guards do the heavy lifting. - -| Concern | Private (`agiletec` / `agile-server` / 他 private) | Public (`cmd-ime` / `airis-mcp-gateway` / `airis-workspace` / `mindbase` / `airis-code` / `homebrew-tap` / 等) | -|---|---|---| -| Blast radius on secret leak | Contained to org boundary; time to rotate | Instant world-wide propagation; no time to react | -| Runtime secret injection | **Doppler required** (`doppler run --` / k8s operator → Secret) | GitHub Secrets / env (acceptable because secrets are scoped, not user-facing) | -| Secret scanning | **GitHub Advanced Security (paid)** on Team/Enterprise — verify with org admin | **Free, default-available** (verify ON in Settings → Code security) | -| Push protection | Paid via GHAS | Free for public repos; **must be enabled** at repo or org level (default is OFF for repos, must be flipped) | -| CodeQL | Paid via GHAS | Free for public repos; add `codeql.yml` workflow | -| Runner | ARC self-hosted (`agiletec-self-hosted-runner`, `airis-studio-runners`, `agile-server-runner`); minimize hosted-minute spend | GitHub-hosted (`ubuntu-latest` / `macos-latest`); free for public | -| Cross-repo PR auth | GitHub App + `create-github-app-token@v3`, scoped permissions. **PAT banned.** | Standard `GITHUB_TOKEN` or minimally-scoped fine-grained PAT | -| Auto-merge | Via the `agiletec-automerge` GitHub App (the default `GITHUB_TOKEN` returns "Resource not accessible by integration" for the enable-auto-merge API) | Same App also works; or use `gh pr merge --auto` from the workflow with a PAT for repos that allow it | -| Reusable CI workflows | Pull from `agiletec-inc/.github` (`secret-scan.yml`, `node-pnpm-ci.yml`, `rust-cargo-ci.yml`, `python-ci.yml`, `swift-ci.yml`, `docker-ghcr-publish.yml`, `auto-merge.yml`) with `runs-on: agiletec-self-hosted-runner` | Pull the same reusables with `runs-on: ubuntu-latest` (default) | - -**The framing "public CI is lighter" is wrong.** Public CI files are -shorter because the heavy lifting is delegated to GitHub's built-in -features (secret scanning, push protection, CodeQL, hosted runner). The -defense total is at least equal — sometimes higher, since built-ins are -maintained by GitHub. Treat public guards as load-bearing infrastructure, -not optional decoration. - -## Required guards per visibility - -### Private repos must have - -- Branch protection: Required checks pass before merge; merge commit only - (squash/rebase disabled by Org Ruleset) -- `secret-scan` reusable workflow (`agiletec-inc/.github/.github/workflows/secret-scan.yml`) - as a required check -- Runtime secrets via Doppler only; no `.env` files in repo -- Cross-repo automation via GitHub Apps with least-privilege scope -- ARC runners only (no GitHub-hosted) for cost containment -- For repos with prod deploy: `environment: prd` with required reviewers - -### Public repos must have - -- Branch protection: Required checks pass before merge -- **Push protection ENABLED** at repo level (Settings → Code security → - Secret scanning → Push protection: Enable) -- **Secret scanning ENABLED** (Settings → Code security → Secret scanning) -- `secret-scan` reusable workflow as a required check (belt-and-braces - with built-in scanning) -- `codeql.yml` workflow as a required check -- `verify-runners` style guard if the repo must stay on GitHub-hosted runner - (see `airis-mcp-gateway/scripts/test-workflow-runners.sh` for a working - reference implementation) -- No `.env` files committed; pre-commit hooks reject any matching - pattern (`.env`, `.env.local`, `.env.*`, `*.pem`, `*credentials*`) -- Dependabot enabled (free, default for public) - -## References - -- [GitHub Actions: events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) -- [GitHub Environments and deployment protection rules](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment) -- [About secret scanning](https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning) -- [GitHub Actions 2026 Security Roadmap](https://github.blog/news-insights/product-news/whats-coming-to-our-github-actions-2026-security-roadmap/) — least-privilege `secrets:` policy, scoped secrets -- Reusable workflows in this repo: `secret-scan.yml`, `node-pnpm-ci.yml`, `rust-cargo-ci.yml`, `python-ci.yml`, `swift-ci.yml`, `docker-ghcr-publish.yml`, `auto-merge.yml` -- Starter templates: `.github/workflow-templates/{node,rust,python,swift}-ci.yml` -- Targeting: org custom property `ci_managed` + ruleset "Org CI required checks" (id 17507867) -- [Well-Architected: rulesets best practices](https://wellarchitected.github.com/library/governance/recommendations/managing-repositories-at-scale/rulesets-best-practices/) (custom-property targeting) diff --git a/policies/org-quality-gate.md b/policies/org-quality-gate.md deleted file mode 100644 index e9874c6..0000000 --- a/policies/org-quality-gate.md +++ /dev/null @@ -1,7 +0,0 @@ -# Organization quality gate契約 - -organization required workflowは全repoへ同じgateを適用する。stack detectionがNode、Bun、Python、Rust、Swiftの -該当jobを選び、secret scan、feature flag check、final aggregatorは常に実行する。 - -repo別の例外機構は設けない。native CIを持つrepoでもgeneric stack jobを実行し、単一で均一なgateを優先する。 -`org-quality-gate.yml` / `quality-gate.yml`へrepo名条件やopt-out inputを再導入しない。 diff --git a/policy_broker/README.md b/policy_broker/README.md deleted file mode 100644 index 292f114..0000000 --- a/policy_broker/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Ruleset workflow pin broker - -AWS Lambda上でGitHub App JWTをKMS `Sign`だけで生成し、organization Rulesetの固定required workflow SHAを更新する独立componentです。 - -- public endpointは作らない。owner-managed AWS identityからLambdaを直接invokeする。 -- `APPLY_ENABLED`は既定`false`。false時はschema、digest、canary、target、live Rulesetを検証して監査へ`approved`を記録するだけ。 -- true時も変更可能なのは固定workflowの`sha` 1値だけ。更新直前にRulesetを再取得し、最初のreadと違えばfail closedにする。 -- GitHub App private keyとinstallation tokenは保存・response・auditへ出さない。 -- DynamoDB tableはretain、PITR有効、`audit_id`の条件付きPutItemでappend-onlyにする。 - -署名providerはAWS KMS、runtimeはAWS Lambdaとする。KMS keyはexternal-origin RSA keyで、runtimeへ許可するのは -`kms:Sign`だけ。long-lived AWS access key、GitHub App private key、installation tokenをlocal Mac、GitHub Actions、 -Doppler、self-hosted runner、AIris VibeOSへ置かない。署名algorithmは`RSASSA_PKCS1_V1_5_SHA_256`に固定する。 - -AWS account ID、region、billing owner、security contact、CloudTrail保存先がownerにより確定するまでproduction -provisioningを開始しない。利用不能時はmutationを停止し、local tokenへfallbackしない。 - -```sh -python3 -m unittest discover -s tests -p 'test_ruleset_pin_broker.py' -``` diff --git a/policy_broker/__init__.py b/policy_broker/__init__.py deleted file mode 100644 index 02dd84e..0000000 --- a/policy_broker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Ruleset workflow pin policy broker.""" diff --git a/policy_broker/ruleset_pin_broker.py b/policy_broker/ruleset_pin_broker.py deleted file mode 100644 index 66bcba7..0000000 --- a/policy_broker/ruleset_pin_broker.py +++ /dev/null @@ -1,410 +0,0 @@ -from __future__ import annotations - -import base64 -import copy -import hashlib -import json -import os -import time -import urllib.error -import urllib.request -import uuid -from dataclasses import dataclass -from typing import Any, Protocol - -ORGANIZATION = "agiletec-inc" -RULESET_ID = 19456040 -WORKFLOW_PATH = ".github/workflows/org-quality-gate.yml" -WORKFLOW_REF = "refs/heads/main" -SOURCE_REPOSITORY = "agiletec-inc/github-actions" -EFFECTIVE_REPOSITORY = "agiletec-inc/agiletec" -API_VERSION = "2026-03-10" -SHA_KEYS = {"proposed_sha"} - - -class RejectedProposal(ValueError): - pass - - -class Conflict(RuntimeError): - pass - - -class GithubFailure(RuntimeError): - pass - - -class Signer(Protocol): - def sign(self, message: bytes) -> bytes: ... - - -class AuditStore(Protocol): - def append(self, record: dict[str, Any]) -> None: ... - - -def _expect_keys(value: Any, required: set[str], where: str) -> dict[str, Any]: - if not isinstance(value, dict) or set(value) != required: - raise RejectedProposal(f"{where} must contain exactly {sorted(required)}") - return value - - -def _is_sha(value: Any) -> bool: - return ( - isinstance(value, str) - and len(value) == 40 - and all(c in "0123456789abcdef" for c in value) - ) - - -def canonical_digest(proposal: dict[str, Any]) -> str: - unsigned = {key: value for key, value in proposal.items() if key != "digest"} - payload = json.dumps( - unsigned, sort_keys=True, separators=(",", ":"), ensure_ascii=False - ) - return f"sha256:{hashlib.sha256(payload.encode()).hexdigest()}" - - -def validate_proposal(proposal: Any, source_repository_id: int) -> dict[str, Any]: - root = _expect_keys( - proposal, - {"schema_version", "operation", "target", "change", "canary", "digest"}, - "proposal", - ) - if root["schema_version"] != 1 or root["operation"] != "ruleset-workflow-pin": - raise RejectedProposal("unsupported proposal operation or schema version") - - target = _expect_keys( - root["target"], {"organization", "ruleset_id", "workflow"}, "target" - ) - workflow = _expect_keys( - target["workflow"], {"repository_id", "path", "ref"}, "workflow" - ) - if ( - target["organization"] != ORGANIZATION - or target["ruleset_id"] != RULESET_ID - or workflow["repository_id"] != source_repository_id - or workflow["path"] != WORKFLOW_PATH - or workflow["ref"] != WORKFLOW_REF - ): - raise RejectedProposal("proposal target does not match the fixed broker target") - - change = _expect_keys(root["change"], SHA_KEYS, "change") - if not _is_sha(change["proposed_sha"]): - raise RejectedProposal("proposed_sha must be a lowercase 40-character SHA") - - canary = _expect_keys( - root["canary"], - { - "repository", - "pull_request", - "head_sha", - "check_name", - "check_run_id", - "workflow_run_id", - "workflow_path", - "conclusion", - }, - "canary", - ) - if ( - canary["repository"] != SOURCE_REPOSITORY - or canary["workflow_path"] != ".github/workflows/ci.yml" - or canary["check_name"] != "test" - or canary["conclusion"] != "success" - or not _is_sha(canary["head_sha"]) - or canary["head_sha"] != change["proposed_sha"] - or not all( - isinstance(canary[key], int) and canary[key] > 0 - for key in ("pull_request", "check_run_id", "workflow_run_id") - ) - ): - raise RejectedProposal("canary does not prove the proposed workflow SHA") - if root["digest"] != canonical_digest(root): - raise RejectedProposal("proposal digest mismatch") - return root - - -def mutation_body(ruleset: dict[str, Any]) -> dict[str, Any]: - keys = ("name", "target", "enforcement", "bypass_actors", "conditions", "rules") - if not all(key in ruleset for key in keys): - raise RejectedProposal("GitHub Ruleset response is missing mutation fields") - return {key: copy.deepcopy(ruleset[key]) for key in keys} - - -def replace_workflow_sha( - ruleset: dict[str, Any], source_repository_id: int, proposed_sha: str -) -> tuple[dict[str, Any], str]: - body = mutation_body(ruleset) - matches: list[dict[str, Any]] = [] - for rule in body["rules"]: - if rule.get("type") != "workflows": - continue - for workflow in rule.get("parameters", {}).get("workflows", []): - if ( - workflow.get("repository_id") == source_repository_id - and workflow.get("path") == WORKFLOW_PATH - and workflow.get("ref") == WORKFLOW_REF - ): - matches.append(workflow) - if len(matches) != 1: - raise RejectedProposal("fixed required workflow target must occur exactly once") - current_sha = matches[0].get("sha") - if not _is_sha(current_sha): - raise RejectedProposal("current required workflow is not SHA pinned") - matches[0]["sha"] = proposed_sha - return body, current_sha - - -def _b64url(value: bytes) -> str: - return base64.urlsafe_b64encode(value).rstrip(b"=").decode() - - -def github_app_jwt(app_id: str, signer: Signer, now: int | None = None) -> str: - timestamp = int(time.time()) if now is None else now - header = _b64url(b'{"alg":"RS256","typ":"JWT"}') - payload = _b64url( - json.dumps( - {"iat": timestamp - 60, "exp": timestamp + 540, "iss": app_id}, - sort_keys=True, - separators=(",", ":"), - ).encode() - ) - signing_input = f"{header}.{payload}" - return f"{signing_input}.{_b64url(signer.sign(signing_input.encode()))}" - - -@dataclass(frozen=True) -class GithubResponse: - body: Any - request_id: str | None - - -class GithubClient: - def __init__(self, token: str, opener: Any = urllib.request.urlopen): - self._token = token - self._opener = opener - - def request( - self, method: str, path: str, body: Any | None = None - ) -> GithubResponse: - data = ( - None if body is None else json.dumps(body, separators=(",", ":")).encode() - ) - request = urllib.request.Request( - f"https://api.github.com{path}", - data=data, - method=method, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {self._token}", - "X-GitHub-Api-Version": API_VERSION, - "User-Agent": "agiletec-ruleset-policy-broker", - }, - ) - for attempt in range(3): - try: - with self._opener(request, timeout=10) as response: - raw = response.read() - return GithubResponse( - json.loads(raw) if raw else None, - response.headers.get("x-github-request-id"), - ) - except urllib.error.HTTPError as error: - try: - detail = error.read().decode(errors="replace")[:500] - finally: - error.close() - if 500 <= error.code < 600 and attempt < 2: - continue - raise GithubFailure( - f"GitHub API {method} {path} failed with {error.code}: {detail}" - ) from error - except (urllib.error.URLError, TimeoutError) as error: - if attempt < 2: - continue - raise GithubFailure( - f"GitHub API {method} {path} transport failure" - ) from error - raise AssertionError("unreachable") - - -def installation_token(app_id: str, installation_id: str, signer: Signer) -> str: - response = GithubClient(github_app_jwt(app_id, signer)).request( - "POST", f"/app/installations/{installation_id}/access_tokens" - ) - token = response.body.get("token") if isinstance(response.body, dict) else None - if not isinstance(token, str) or not token: - raise GithubFailure("GitHub did not return an installation token") - return token - - -def verify_canary(github: GithubClient, proposal: dict[str, Any]) -> None: - canary = proposal["canary"] - check = github.request( - "GET", f"/repos/{SOURCE_REPOSITORY}/check-runs/{canary['check_run_id']}" - ).body - run = github.request( - "GET", f"/repos/{SOURCE_REPOSITORY}/actions/runs/{canary['workflow_run_id']}" - ).body - check_suite_id = ( - check.get("check_suite", {}).get("id") if isinstance(check, dict) else None - ) - pull_requests = run.get("pull_requests", []) if isinstance(run, dict) else [] - if ( - not isinstance(check, dict) - or not isinstance(run, dict) - or check.get("name") != canary["check_name"] - or check.get("head_sha") != canary["head_sha"] - or check.get("conclusion") != "success" - or run.get("id") != canary["workflow_run_id"] - or run.get("head_sha") != canary["head_sha"] - or run.get("conclusion") != "success" - or run.get("event") != "pull_request" - or run.get("path") != canary["workflow_path"] - or run.get("repository", {}).get("full_name") != SOURCE_REPOSITORY - or run.get("check_suite_id") != check_suite_id - or not any( - item.get("number") == canary["pull_request"] for item in pull_requests - ) - ): - raise RejectedProposal("GitHub read-back did not verify the proposal canary") - - -class RulesetBroker: - def __init__( - self, - github: GithubClient, - audit: AuditStore, - source_repository_id: int, - apply_enabled: bool, - ): - self.github = github - self.audit = audit - self.source_repository_id = source_repository_id - self.apply_enabled = apply_enabled - - def apply(self, proposal: Any, actor: str, workload: str) -> dict[str, Any]: - audit_id = str(uuid.uuid4()) - timestamp = int(time.time()) - digest = proposal.get("digest") if isinstance(proposal, dict) else None - record: dict[str, Any] = { - "audit_id": audit_id, - "proposal_digest": digest, - "actor": actor, - "workload": workload, - "timestamp": timestamp, - } - try: - validated = validate_proposal(proposal, self.source_repository_id) - verify_canary(self.github, validated) - proposed_sha = validated["change"]["proposed_sha"] - path = f"/orgs/{ORGANIZATION}/rulesets/{RULESET_ID}" - initial = self.github.request("GET", path) - desired, before_sha = replace_workflow_sha( - initial.body, self.source_repository_id, proposed_sha - ) - record.update({"before_sha": before_sha, "after_sha": proposed_sha}) - if before_sha == proposed_sha: - record["result"] = "applied" - self.audit.append(record) - return {"status": "applied", "audit_id": audit_id, "idempotent": True} - if not self.apply_enabled: - record["result"] = "approved" - self.audit.append(record) - return {"status": "approved", "audit_id": audit_id, "dry_run": True} - - current = self.github.request("GET", path) - if mutation_body(current.body) != mutation_body(initial.body): - raise Conflict("Ruleset changed after admission read") - updated = self.github.request("PUT", path, desired) - applied, applied_sha = replace_workflow_sha( - updated.body, self.source_repository_id, proposed_sha - ) - if applied_sha != proposed_sha or applied != desired: - raise GithubFailure( - "organization Ruleset read-back did not match the candidate" - ) - effective = self.github.request( - "GET", - f"/repos/{EFFECTIVE_REPOSITORY}/rulesets/{RULESET_ID}?includes_parents=true", - ) - _, effective_sha = replace_workflow_sha( - effective.body, self.source_repository_id, proposed_sha - ) - if effective_sha != proposed_sha: - raise GithubFailure( - "effective repository Ruleset did not expose the candidate" - ) - record.update( - {"result": "applied", "github_request_id": updated.request_id} - ) - self.audit.append(record) - return {"status": "applied", "audit_id": audit_id, "idempotent": False} - except RejectedProposal as error: - record.update({"result": "rejected", "reason": str(error)}) - self.audit.append(record) - return {"status": "rejected", "audit_id": audit_id, "reason": str(error)} - except (Conflict, GithubFailure) as error: - record.update({"result": "failed", "reason": str(error)}) - self.audit.append(record) - return {"status": "failed", "audit_id": audit_id, "reason": str(error)} - - -class KmsSigner: - def __init__(self, kms: Any, key_id: str): - self.kms = kms - self.key_id = key_id - - def sign(self, message: bytes) -> bytes: - response = self.kms.sign( - KeyId=self.key_id, - Message=message, - MessageType="RAW", - SigningAlgorithm="RSASSA_PKCS1_V1_5_SHA_256", - ) - return response["Signature"] - - -class DynamoAuditStore: - def __init__(self, table: Any): - self.table = table - - def append(self, record: dict[str, Any]) -> None: - self.table.put_item( - Item=record, ConditionExpression="attribute_not_exists(audit_id)" - ) - - -def lambda_handler(event: dict[str, Any], _context: Any) -> dict[str, Any]: - import boto3 - - required_env = ( - "GITHUB_APP_ID", - "GITHUB_INSTALLATION_ID", - "KMS_KEY_ARN", - "AUDIT_TABLE", - "SOURCE_REPOSITORY_ID", - ) - missing = [name for name in required_env if not os.environ.get(name)] - if missing: - raise RuntimeError(f"missing broker configuration: {', '.join(missing)}") - source_repository_id = int(os.environ["SOURCE_REPOSITORY_ID"]) - signer = KmsSigner(boto3.client("kms"), os.environ["KMS_KEY_ARN"]) - token = installation_token( - os.environ["GITHUB_APP_ID"], os.environ["GITHUB_INSTALLATION_ID"], signer - ) - github = GithubClient(token) - audit = DynamoAuditStore( - boto3.resource("dynamodb").Table(os.environ["AUDIT_TABLE"]) - ) - broker = RulesetBroker( - github, - audit, - source_repository_id, - os.environ.get("APPLY_ENABLED") == "true", - ) - return broker.apply( - event.get("proposal"), - str(event.get("actor", "unknown"))[:200], - str(event.get("workload", "unknown"))[:200], - ) diff --git a/policy_broker/template.yaml b/policy_broker/template.yaml deleted file mode 100644 index d02e883..0000000 --- a/policy_broker/template.yaml +++ /dev/null @@ -1,59 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: Sign-only GitHub organization Ruleset workflow pin broker - -Parameters: - GitHubAppId: - Type: String - GitHubInstallationId: - Type: String - KmsKeyArn: - Type: String - SourceRepositoryId: - Type: Number - ApplyEnabled: - Type: String - Default: 'false' - AllowedValues: ['false', 'true'] - -Resources: - BrokerAudit: - Type: AWS::DynamoDB::Table - DeletionPolicy: Retain - UpdateReplacePolicy: Retain - Properties: - BillingMode: PAY_PER_REQUEST - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: true - AttributeDefinitions: - - AttributeName: audit_id - AttributeType: S - KeySchema: - - AttributeName: audit_id - KeyType: HASH - - RulesetPinBroker: - Type: AWS::Serverless::Function - Properties: - Runtime: python3.14 - Handler: policy_broker.ruleset_pin_broker.lambda_handler - CodeUri: .. - Timeout: 30 - MemorySize: 256 - Environment: - Variables: - GITHUB_APP_ID: !Ref GitHubAppId - GITHUB_INSTALLATION_ID: !Ref GitHubInstallationId - KMS_KEY_ARN: !Ref KmsKeyArn - SOURCE_REPOSITORY_ID: !Ref SourceRepositoryId - AUDIT_TABLE: !Ref BrokerAudit - APPLY_ENABLED: !Ref ApplyEnabled - Policies: - - AWSLambdaBasicExecutionRole - - Statement: - - Effect: Allow - Action: kms:Sign - Resource: !Ref KmsKeyArn - - Effect: Allow - Action: dynamodb:PutItem - Resource: !GetAtt BrokerAudit.Arn diff --git a/profile/README.md b/profile/README.md index c1b8c22..facec0b 100644 --- a/profile/README.md +++ b/profile/README.md @@ -1,159 +1,10 @@ -# Agiletec Inc. +# Agiletec -**"Empowering every company to develop in-house. Ending the multi-tier subcontracting structure."** +人とAIが安全に協働できる業務ソフトウェアを開発しています。 -[![GitHub Organization](https://img.shields.io/badge/GitHub-agiletec--inc-181717?logo=github)](https://github.com/agiletec-inc) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md) -[![Website](https://img.shields.io/badge/Website-agiletec.net-blue)](https://agiletec.net) +- 製品・OSSは各リポジトリのREADMEを参照してください。 +- contributionは[CONTRIBUTING.md](../CONTRIBUTING.md)を参照してください。 +- security issueは[SECURITY.md](../SECURITY.md)の窓口へ非公開で報告してください。 -**日本語**: コーポレートサイトは [agiletec.net](https://agiletec.net) をご覧ください - ---- - -## 🎯 Vision & Mission - -**Vision**: In-house development for every company. -**Mission**: Eliminate the multi-tier subcontracting structure. - -We are a technology company that helps businesses regain their ability to build and create through AI-powered companion-style development. - -👉 **Learn more in [VISION.md](https://github.com/agiletec-inc/.github/blob/master/VISION.md)** (Japanese) - ---- - -## 🚀 Open Source Projects - -### 🤖 AI & LLM Tools - -#### **[airis-mcp-gateway](https://github.com/agiletec-inc/airis-mcp-gateway)** ⭐ 32 stars -High-performance MCP (Model Context Protocol) gateway that reduces token usage by 90% through intelligent parallel processing and routing. - -- **Tech Stack**: Python, FastMCP, TypeScript, PostgreSQL -- **Key Features**: Intelligent routing, error handling, performance monitoring, settings UI -- **Status**: 🟢 Production - -#### **[superagent](https://github.com/agiletec-inc/superagent)** -Configuration framework that enhances Claude Code with specialized commands, cognitive personas, and development methodologies. - -- **Tech Stack**: Python -- **Key Features**: Custom slash commands, agent orchestration, confidence checks, deep research -- **Status**: 🟢 Active Development - -#### **[mindbase](https://github.com/agiletec-inc/mindbase)** -AI conversation knowledge management system - fully local, free, and open source. - -- **Tech Stack**: Python, PostgreSQL, pgvector, Ollama -- **Key Features**: Local AI embeddings, vector search, conversation memory, privacy-first -- **Status**: 🟢 Active Development - -#### **[airis-translate](https://github.com/agiletec-inc/airis-translate)** -Native macOS translation app using Ollama for private, offline translation. - -- **Tech Stack**: Swift, SwiftUI, Ollama -- **Key Features**: DeepL-style quick access, selection translation, translation history -- **Status**: 🟡 Beta - ---- - -### 🛠️ Developer Tools - -#### **[selfhosted-supabase-mcp](https://github.com/agiletec-inc/selfhosted-supabase-mcp)** -MCP server enabling database introspection and authentication management for self-hosted Supabase instances. - -- **Tech Stack**: TypeScript, Supabase, MCP -- **Key Features**: Database schema inspection, auth management, self-hosted support -- **Status**: 🟢 Production - -#### **[cmd-ime](https://github.com/agiletec-inc/cmd-ime)** -macOS input method switcher using Rust and Tauri for seamless keyboard language switching. - -- **Tech Stack**: TypeScript, Rust, Tauri -- **Key Features**: Fast IME switching, macOS native integration, lightweight -- **Status**: 🟡 Beta - ---- - -### 📦 Distribution - -#### **[homebrew-tap](https://github.com/agiletec-inc/homebrew-tap)** -Official Homebrew tap for distributing Agiletec packages on macOS. - -- **Tech Stack**: Ruby, Homebrew -- **Purpose**: Package distribution for macOS users -- **Status**: 🟢 Active - -#### **[homebrew-mindbase](https://github.com/agiletec-inc/homebrew-mindbase)** -Dedicated Homebrew tap for MindBase distribution. - -- **Tech Stack**: Ruby, Homebrew -- **Purpose**: Simplified MindBase installation via `brew install` -- **Status**: 🟢 Active - ---- - -## 🏗️ Philosophy - -- **Transparency** – Make processes and decisions visible -- **Empowerment** – Enable self-sufficiency, not dependency -- **Craftsmanship** – Value technical excellence and pride in work -- **Agility** – Embrace change and rapid iteration - ---- - -## 🤝 Contributing - -We welcome contributions to all our projects! -Please read [CONTRIBUTING.md](https://github.com/agiletec-inc/.github/blob/master/CONTRIBUTING.md) for details. - -**Code Standards**: -- **TypeScript/JavaScript**: ESLint + Prettier, explicit types -- **Python**: Black + Ruff, type hints (Python 3.12+) -- **Principles**: SOLID, DRY, YAGNI, KISS -- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) - ---- - -## 💖 Support Our Work - -If our open-source projects help you, consider supporting us: - -[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?logo=github)](https://github.com/sponsors/agiletec-inc) - -**Why Sponsor?** -- 🚀 Accelerate development of open-source tools -- 🎓 Support educational content and tutorials -- 🌍 Help us eliminate multi-tier contracting structures in Japan's IT industry -- 💡 Get early access to new features and products - -**Enterprise Support**: For commercial support, consulting, or custom development, [contact us](mailto:hello@agiletec.net) - ---- - -## 📖 Documentation - -- **[VISION.md](https://github.com/agiletec-inc/.github/blob/master/VISION.md)** - Company philosophy, mission, and strategy (Japanese) -- **[PROJECTS.md](https://github.com/agiletec-inc/.github/blob/master/PROJECTS.md)** - Project portfolio (Japanese) -- **[CONTRIBUTING.md](https://github.com/agiletec-inc/.github/blob/master/CONTRIBUTING.md)** - Contribution guidelines -- **[CODE_OF_CONDUCT.md](https://github.com/agiletec-inc/.github/blob/master/CODE_OF_CONDUCT.md)** - Community standards -- **[SECURITY.md](https://github.com/agiletec-inc/.github/blob/master/SECURITY.md)** - Security policy -- **[CHANGELOG.md](https://github.com/agiletec-inc/.github/blob/master/CHANGELOG.md)** - Organizational changelog - ---- - -## 🌐 Connect With Us - -- 🌍 **Website**: [agiletec.net](https://agiletec.net) -- 💼 **GitHub**: [@agiletec-inc](https://github.com/agiletec-inc) -- 📧 **Email**: [hello@agiletec.net](mailto:hello@agiletec.net) -- 🐦 **X (Twitter)**: [@agiletec_inc](https://x.com/agiletec_inc) - ---- - -
- -**Founded 2025, Tokyo, Japan** - -*Building a world where every company can develop in-house.* - -
+組織共通のCI実装とpolicyはprivate `agiletec-inc/github-actions`が所有します。このpublic `.github`は +organization profile、community health、workflow starter templateだけを提供します。 diff --git a/schemas/ruleset-workflow-pin-proposal-v1.schema.json b/schemas/ruleset-workflow-pin-proposal-v1.schema.json deleted file mode 100644 index f4bcbc3..0000000 --- a/schemas/ruleset-workflow-pin-proposal-v1.schema.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://agiletec.net/schemas/ruleset-workflow-pin-proposal-v1.json", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "operation", - "target", - "change", - "canary", - "digest" - ], - "properties": { - "schema_version": { "const": 1 }, - "operation": { "const": "ruleset-workflow-pin" }, - "target": { - "type": "object", - "additionalProperties": false, - "required": ["organization", "ruleset_id", "workflow"], - "properties": { - "organization": { "const": "agiletec-inc" }, - "ruleset_id": { "const": 19456040 }, - "workflow": { - "type": "object", - "additionalProperties": false, - "required": ["repository_id", "path", "ref"], - "properties": { - "repository_id": { "type": "integer" }, - "path": { "const": ".github/workflows/org-quality-gate.yml" }, - "ref": { "const": "refs/heads/main" } - } - } - } - }, - "change": { - "type": "object", - "additionalProperties": false, - "required": ["proposed_sha"], - "properties": { "proposed_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" } } - }, - "canary": { - "type": "object", - "additionalProperties": false, - "required": [ - "repository", - "pull_request", - "head_sha", - "check_name", - "check_run_id", - "workflow_run_id", - "workflow_path", - "conclusion" - ], - "properties": { - "repository": { "const": "agiletec-inc/github-actions" }, - "pull_request": { "type": "integer", "minimum": 1 }, - "head_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - "check_name": { "const": "test" }, - "check_run_id": { "type": "integer", "minimum": 1 }, - "workflow_run_id": { "type": "integer", "minimum": 1 }, - "workflow_path": { "const": ".github/workflows/ci.yml" }, - "conclusion": { "const": "success" } - } - }, - "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" } - } -} diff --git a/tests/test_feature_flag_check.py b/tests/test_feature_flag_check.py deleted file mode 100644 index b74f302..0000000 --- a/tests/test_feature_flag_check.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -import tempfile -import textwrap -import unittest -from pathlib import Path - - -SCRIPT = Path(__file__).parents[1] / ".github/scripts/feature_flag_check.py" - - -class FeatureFlagCheckTests(unittest.TestCase): - def run_check(self, manifest: str | None) -> subprocess.CompletedProcess[str]: - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - if manifest is not None: - airis = root / ".airis" - airis.mkdir() - (airis / "flags.toml").write_text(textwrap.dedent(manifest)) - return subprocess.run( - [sys.executable, str(SCRIPT), "--root", str(root), "--today", "2026-07-22"], - text=True, - capture_output=True, - check=False, - ) - - def test_skips_repositories_without_a_manifest(self) -> None: - result = self.run_check(None) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_runs_temporary_flag_off_and_on_tests(self) -> None: - result = self.run_check( - """ - [[flags]] - key = "checkout.v2" - kind = "release" - type = "boolean" - owner = "team:billing" - expires = "2026-12-31" - cleanup_issue = "https://github.com/agiletec-inc/example/issues/123" - - [flags.tests.off] - command = 'test "$CHECKOUT_V2" = false' - environment = { CHECKOUT_V2 = "false" } - - [flags.tests.on] - command = 'test "$CHECKOUT_V2" = true' - environment = { CHECKOUT_V2 = "true" } - """ - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("Feature flag check passed: 1 definitions", result.stdout) - - def test_rejects_temporary_flags_without_cleanup_or_tests(self) -> None: - result = self.run_check( - """ - [[flags]] - key = "checkout.v2" - kind = "release" - type = "boolean" - owner = "team:billing" - expires = "2026-12-31" - """ - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("cleanup_issue", result.stderr) - - def test_rejects_expired_temporary_flags(self) -> None: - result = self.run_check( - """ - [[flags]] - key = "checkout.v2" - kind = "experiment" - type = "boolean" - owner = "team:billing" - expires = "2020-01-01" - cleanup_issue = "https://github.com/agiletec-inc/example/issues/123" - - [flags.tests.off] - command = "true" - - [flags.tests.on] - command = "true" - """ - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("expired", result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_policy_broker_provider_contract.py b/tests/test_policy_broker_provider_contract.py deleted file mode 100644 index 2ccb2e9..0000000 --- a/tests/test_policy_broker_provider_contract.py +++ /dev/null @@ -1,24 +0,0 @@ -from pathlib import Path -import unittest - - -ROOT = Path(__file__).resolve().parents[1] -BROKER = (ROOT / "policy_broker" / "ruleset_pin_broker.py").read_text() -TEMPLATE = (ROOT / "policy_broker" / "template.yaml").read_text() - - -class PolicyBrokerProviderContractTest(unittest.TestCase): - def test_uses_sign_only_kms_and_default_deny(self) -> None: - self.assertIn('Action: kms:Sign', TEMPLATE) - self.assertNotIn('kms:Decrypt', TEMPLATE) - self.assertIn("Default: 'false'", TEMPLATE) - self.assertIn('RSASSA_PKCS1_V1_5_SHA_256', BROKER) - - def test_fixes_the_non_weakening_target(self) -> None: - self.assertIn('RULESET_ID = 19456040', BROKER) - self.assertIn('root["operation"] != "ruleset-workflow-pin"', BROKER) - self.assertIn('WORKFLOW_PATH = ".github/workflows/org-quality-gate.yml"', BROKER) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ruleset_pin_broker.py b/tests/test_ruleset_pin_broker.py deleted file mode 100644 index 60b5acd..0000000 --- a/tests/test_ruleset_pin_broker.py +++ /dev/null @@ -1,339 +0,0 @@ -import copy -import io -import json -import unittest -import urllib.error - -from policy_broker.ruleset_pin_broker import ( - EFFECTIVE_REPOSITORY, - ORGANIZATION, - RULESET_ID, - SOURCE_REPOSITORY, - WORKFLOW_PATH, - WORKFLOW_REF, - GithubResponse, - GithubClient, - GithubFailure, - RejectedProposal, - RulesetBroker, - canonical_digest, - github_app_jwt, - replace_workflow_sha, - validate_proposal, -) - -SOURCE_ID = 1234 -OLD_SHA = "1" * 40 -NEW_SHA = "2" * 40 - - -def proposal(): - value = { - "schema_version": 1, - "operation": "ruleset-workflow-pin", - "target": { - "organization": ORGANIZATION, - "ruleset_id": RULESET_ID, - "workflow": { - "repository_id": SOURCE_ID, - "path": WORKFLOW_PATH, - "ref": WORKFLOW_REF, - }, - }, - "change": {"proposed_sha": NEW_SHA}, - "canary": { - "repository": SOURCE_REPOSITORY, - "pull_request": 26, - "head_sha": NEW_SHA, - "check_name": "test", - "check_run_id": 10, - "workflow_run_id": 11, - "workflow_path": ".github/workflows/ci.yml", - "conclusion": "success", - }, - "digest": "", - } - value["digest"] = canonical_digest(value) - return value - - -def ruleset(sha=OLD_SHA): - return { - "id": RULESET_ID, - "name": "Main Branch Protection", - "target": "branch", - "enforcement": "active", - "bypass_actors": [], - "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, - "rules": [ - { - "type": "pull_request", - "parameters": {"required_approving_review_count": 0}, - }, - { - "type": "workflows", - "parameters": { - "do_not_enforce_on_create": False, - "workflows": [ - { - "repository_id": SOURCE_ID, - "path": WORKFLOW_PATH, - "ref": WORKFLOW_REF, - "sha": sha, - } - ], - }, - }, - ], - } - - -def canary_responses(): - return [ - { - "name": "test", - "head_sha": NEW_SHA, - "conclusion": "success", - "check_suite": {"id": 99}, - }, - { - "id": 11, - "head_sha": NEW_SHA, - "conclusion": "success", - "event": "pull_request", - "path": ".github/workflows/ci.yml", - "repository": {"full_name": SOURCE_REPOSITORY}, - "check_suite_id": 99, - "pull_requests": [{"number": 26}], - }, - ] - - -class FakeAudit: - def __init__(self): - self.records = [] - - def append(self, record): - self.records.append(record) - - -class FakeGithub: - def __init__(self, responses): - self.responses = list(responses) - self.requests = [] - - def request(self, method, path, body=None): - self.requests.append((method, path, body)) - response = self.responses.pop(0) - if isinstance(response, Exception): - raise response - return GithubResponse(copy.deepcopy(response), "request-id") - - -class ProposalTests(unittest.TestCase): - def test_accepts_exact_schema_and_digest(self): - self.assertEqual( - validate_proposal(proposal(), SOURCE_ID)["change"]["proposed_sha"], NEW_SHA - ) - - def test_rejects_unknown_field_digest_and_target(self): - for mutate in ( - lambda value: value.update({"extra": True}), - lambda value: value.update({"digest": "sha256:" + "0" * 64}), - lambda value: value["target"].update({"ruleset_id": 1}), - ): - value = proposal() - mutate(value) - with self.subTest(value=value), self.assertRaises(RejectedProposal): - validate_proposal(value, SOURCE_ID) - - def test_canary_must_prove_candidate(self): - value = proposal() - value["canary"]["head_sha"] = OLD_SHA - value["digest"] = canonical_digest(value) - with self.assertRaises(RejectedProposal): - validate_proposal(value, SOURCE_ID) - - -class RulesetTests(unittest.TestCase): - def test_only_sha_changes(self): - current = ruleset() - desired, before = replace_workflow_sha(current, SOURCE_ID, NEW_SHA) - self.assertEqual(before, OLD_SHA) - self.assertEqual( - current["rules"][1]["parameters"]["workflows"][0]["sha"], OLD_SHA - ) - self.assertEqual( - desired["rules"][1]["parameters"]["workflows"][0]["sha"], NEW_SHA - ) - - def test_rejects_unpinned_or_duplicate_target(self): - unpinned = ruleset() - del unpinned["rules"][1]["parameters"]["workflows"][0]["sha"] - duplicate = ruleset() - duplicate["rules"][1]["parameters"]["workflows"].append( - copy.deepcopy(duplicate["rules"][1]["parameters"]["workflows"][0]) - ) - for value in (unpinned, duplicate): - with self.subTest(value=value), self.assertRaises(RejectedProposal): - replace_workflow_sha(value, SOURCE_ID, NEW_SHA) - - -class BrokerTests(unittest.TestCase): - def test_kill_switch_defaults_to_dry_run(self): - audit = FakeAudit() - github = FakeGithub([*canary_responses(), ruleset()]) - result = RulesetBroker(github, audit, SOURCE_ID, False).apply( - proposal(), "owner", "test" - ) - self.assertEqual(result["status"], "approved") - self.assertTrue(result["dry_run"]) - self.assertEqual( - [request[0] for request in github.requests], ["GET", "GET", "GET"] - ) - self.assertEqual(audit.records[0]["result"], "approved") - - def test_applies_after_cas_and_reads_back_org_and_effective_rulesets(self): - audit = FakeAudit() - github = FakeGithub( - [ - *canary_responses(), - ruleset(), - ruleset(), - ruleset(NEW_SHA), - ruleset(NEW_SHA), - ] - ) - result = RulesetBroker(github, audit, SOURCE_ID, True).apply( - proposal(), "owner", "test" - ) - self.assertEqual( - result, - {"status": "applied", "audit_id": result["audit_id"], "idempotent": False}, - ) - self.assertEqual( - [request[0] for request in github.requests], - ["GET", "GET", "GET", "GET", "PUT", "GET"], - ) - self.assertIn( - f"/repos/{EFFECTIVE_REPOSITORY}/rulesets/{RULESET_ID}", - github.requests[-1][1], - ) - self.assertEqual(audit.records[0]["github_request_id"], "request-id") - - def test_conflict_fails_closed_without_put(self): - changed = ruleset() - changed["enforcement"] = "evaluate" - audit = FakeAudit() - github = FakeGithub([*canary_responses(), ruleset(), changed]) - result = RulesetBroker(github, audit, SOURCE_ID, True).apply( - proposal(), "owner", "test" - ) - self.assertEqual(result["status"], "failed") - self.assertEqual( - [request[0] for request in github.requests], ["GET", "GET", "GET", "GET"] - ) - - def test_idempotent_candidate_does_not_mutate(self): - audit = FakeAudit() - github = FakeGithub([*canary_responses(), ruleset(NEW_SHA)]) - result = RulesetBroker(github, audit, SOURCE_ID, True).apply( - proposal(), "owner", "test" - ) - self.assertTrue(result["idempotent"]) - self.assertEqual( - [request[0] for request in github.requests], ["GET", "GET", "GET"] - ) - - def test_forged_canary_is_rejected_before_ruleset_read(self): - forged = canary_responses() - forged[1]["check_suite_id"] = 100 - audit = FakeAudit() - github = FakeGithub(forged) - result = RulesetBroker(github, audit, SOURCE_ID, True).apply( - proposal(), "owner", "test" - ) - self.assertEqual(result["status"], "rejected") - self.assertEqual(len(github.requests), 2) - - -class JwtTests(unittest.TestCase): - def test_kms_signer_receives_header_and_payload_only(self): - class FakeSigner: - def __init__(self): - self.message = None - - def sign(self, message): - self.message = message - return b"signature" - - signer = FakeSigner() - token = github_app_jwt("client-id", signer, now=1000) - self.assertEqual(token.count("."), 2) - self.assertEqual(signer.message.decode(), token.rsplit(".", 1)[0]) - self.assertNotIn("signature", token) - - -class GithubClientTests(unittest.TestCase): - class Response: - def __init__(self, value): - self.value = value - self.headers = {"x-github-request-id": "request-id"} - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - def read(self): - return json.dumps(self.value).encode() - - @staticmethod - def error(status): - return urllib.error.HTTPError( - "https://api.github.com/test", - status, - "failure", - {}, - io.BytesIO(b'{"message":"failure"}'), - ) - - def test_retries_5xx_then_succeeds(self): - responses = [self.error(500), self.error(503), self.Response({"ok": True})] - - def opener(_request, timeout): - self.assertEqual(timeout, 10) - response = responses.pop(0) - if isinstance(response, Exception): - raise response - return response - - result = GithubClient("secret", opener).request("GET", "/test") - self.assertEqual(result.body, {"ok": True}) - self.assertEqual(responses, []) - - def test_409_and_422_fail_without_retry(self): - for status in (409, 422): - calls = [] - - def opener(_request, timeout): - calls.append(timeout) - raise self.error(status) - - with self.subTest(status=status), self.assertRaises(GithubFailure): - GithubClient("secret", opener).request("PUT", "/test", {}) - self.assertEqual(calls, [10]) - - def test_exhausted_5xx_does_not_expose_token(self): - def opener(_request, timeout): - self.assertEqual(timeout, 10) - raise self.error(500) - - with self.assertRaises(GithubFailure) as failure: - GithubClient("top-secret", opener).request("GET", "/test") - self.assertNotIn("top-secret", str(failure.exception)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ruleset_workflow_pin_proposal.py b/tests/test_ruleset_workflow_pin_proposal.py deleted file mode 100644 index 6d68f5d..0000000 --- a/tests/test_ruleset_workflow_pin_proposal.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -import hashlib -import importlib.util -import json -import pathlib -import unittest - - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "tools" / "generate_required_workflow_pin_proposal.py" -SPEC = importlib.util.spec_from_file_location("ruleset_pin_proposal", SCRIPT) -assert SPEC is not None and SPEC.loader is not None -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - -PROPOSED_SHA = "2" * 40 -REPOSITORY_ID = 12345 - - -class FakeReader: - def __init__(self, responses: dict[str, dict[str, object]]) -> None: - self.responses = responses - self.paths: list[str] = [] - - def get_object(self, path: str) -> dict[str, object]: - self.paths.append(path) - return self.responses[path] - - -def responses() -> dict[str, dict[str, object]]: - repository_path = "/repos/agiletec-inc/github-actions" - return { - repository_path: {"id": REPOSITORY_ID, "default_branch": "main"}, - f"{repository_path}/commits/{PROPOSED_SHA}": {"sha": PROPOSED_SHA}, - f"{repository_path}/compare/{PROPOSED_SHA}...main": {"status": "ahead"}, - f"{repository_path}/pulls/25": { - "state": "closed", - "merged_at": "2026-07-27T00:00:00Z", - "base": {"ref": "main"}, - "head": {"sha": PROPOSED_SHA}, - }, - f"{repository_path}/commits/{PROPOSED_SHA}/check-runs?filter=latest&per_page=100": { - "check_runs": [ - { - "id": 88, - "name": "test", - "status": "completed", - "conclusion": "success", - "details_url": "https://github.com/agiletec-inc/github-actions/actions/runs/77/job/99", - "app": {"slug": "github-actions"}, - } - ] - }, - f"{repository_path}/actions/runs/77": { - "head_sha": PROPOSED_SHA, - "path": ".github/workflows/ci.yml", - "status": "completed", - "conclusion": "success", - }, - "/repos/agiletec-inc/agiletec/rulesets/19456040": { - "id": 19456040, - "source_type": "Organization", - "source": "agiletec-inc", - "enforcement": "active", - "rules": [ - { - "type": "workflows", - "parameters": { - "workflows": [ - { - "repository_id": REPOSITORY_ID, - "path": ".github/workflows/org-quality-gate.yml", - "ref": "refs/heads/main", - } - ] - }, - } - ], - }, - } - - -class ProposalTest(unittest.TestCase): - def test_build_proposal_is_deterministic_and_digest_covers_unsigned_body( - self, - ) -> None: - canary = { - "repository": "agiletec-inc/github-actions", - "pull_request": 25, - "head_sha": PROPOSED_SHA, - "check_name": "test", - "check_run_id": 88, - "workflow_run_id": 77, - "workflow_path": ".github/workflows/ci.yml", - "conclusion": "success", - } - first = MODULE.build_proposal(REPOSITORY_ID, PROPOSED_SHA, canary) - second = MODULE.build_proposal(REPOSITORY_ID, PROPOSED_SHA, canary) - self.assertEqual(first, second) - unsigned = {key: value for key, value in first.items() if key != "digest"} - expected = hashlib.sha256(MODULE.canonical_json(unsigned).encode()).hexdigest() - self.assertEqual(first["digest"], f"sha256:{expected}") - - def test_validates_candidate_canary_and_effective_ruleset(self) -> None: - reader = FakeReader(responses()) - repository_id = MODULE.validate_candidate(reader, PROPOSED_SHA) - canary = MODULE.validate_canary(reader, 25, PROPOSED_SHA) - ruleset = reader.get_object("/repos/agiletec-inc/agiletec/rulesets/19456040") - MODULE.validate_effective_workflow(ruleset, repository_id) - self.assertEqual(canary["workflow_run_id"], 77) - - def test_rejects_candidate_not_reachable_from_main(self) -> None: - payloads = responses() - payloads[f"/repos/agiletec-inc/github-actions/compare/{PROPOSED_SHA}...main"][ - "status" - ] = "diverged" - with self.assertRaisesRegex(MODULE.ProposalError, "not reachable"): - MODULE.validate_candidate(FakeReader(payloads), PROPOSED_SHA) - - def test_rejects_canary_head_mismatch(self) -> None: - payloads = responses() - payloads["/repos/agiletec-inc/github-actions/pulls/25"]["head"] = { - "sha": "3" * 40 - } - with self.assertRaisesRegex(MODULE.ProposalError, "head does not match"): - MODULE.validate_canary(FakeReader(payloads), 25, PROPOSED_SHA) - - def test_rejects_spoofed_or_unsuccessful_check(self) -> None: - payloads = responses() - checks = payloads[ - f"/repos/agiletec-inc/github-actions/commits/{PROPOSED_SHA}/check-runs?filter=latest&per_page=100" - ]["check_runs"] - assert isinstance(checks, list) - checks[0]["app"] = {"slug": "third-party"} - with self.assertRaisesRegex(MODULE.ProposalError, "Expected one canary check"): - MODULE.validate_canary(FakeReader(payloads), 25, PROPOSED_SHA) - - def test_rejects_wrong_workflow_run(self) -> None: - payloads = responses() - payloads["/repos/agiletec-inc/github-actions/actions/runs/77"]["path"] = ( - ".github/workflows/other.yml" - ) - with self.assertRaisesRegex(MODULE.ProposalError, "does not match"): - MODULE.validate_canary(FakeReader(payloads), 25, PROPOSED_SHA) - - def test_rejects_inactive_or_ambiguous_effective_ruleset(self) -> None: - ruleset = responses()["/repos/agiletec-inc/agiletec/rulesets/19456040"] - ruleset["enforcement"] = "disabled" - with self.assertRaisesRegex(MODULE.ProposalError, "not active"): - MODULE.validate_effective_workflow(ruleset, REPOSITORY_ID) - - def test_schema_and_script_keep_fixed_authority_boundary(self) -> None: - schema = json.loads( - ( - ROOT / "schemas" / "ruleset-workflow-pin-proposal-v1.schema.json" - ).read_text() - ) - self.assertEqual( - schema["properties"]["operation"]["const"], "ruleset-workflow-pin" - ) - target = schema["properties"]["target"]["properties"] - self.assertEqual(target["organization"]["const"], "agiletec-inc") - self.assertEqual(target["ruleset_id"]["const"], 19456040) - source = SCRIPT.read_text() - self.assertNotRegex(source, r'method="(?:POST|PUT|PATCH|DELETE)"') - self.assertNotIn("--token", source) - self.assertNotIn("admin:org", source) - self.assertNotIn("dotenv", source) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/generate_required_workflow_pin_proposal.py b/tools/generate_required_workflow_pin_proposal.py deleted file mode 100644 index 1c318e5..0000000 --- a/tools/generate_required_workflow_pin_proposal.py +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import sys -import urllib.error -import urllib.request -from typing import Any - - -ORGANIZATION = "agiletec-inc" -SOURCE_REPOSITORY = "github-actions" -CANARY_REPOSITORY = "github-actions" -EFFECTIVE_REPOSITORY = "agiletec" -RULESET_ID = 19456040 -TARGET_WORKFLOW = ".github/workflows/org-quality-gate.yml" -CANARY_WORKFLOW = ".github/workflows/ci.yml" -CANARY_CHECK = "test" - - -class ProposalError(RuntimeError): - pass - - -class GitHubReader: - def __init__(self, base_url: str, token: str) -> None: - self.base_url = base_url.rstrip("/") - self.token = token - - def get_object(self, path: str) -> dict[str, Any]: - request = urllib.request.Request( - f"{self.base_url}{path}", - method="GET", - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {self.token}", - "X-GitHub-Api-Version": "2026-03-10", - }, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: - payload = response.read() - except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error: - raise ProposalError(f"GitHub API GET {path} failed: {error}") from error - try: - decoded = json.loads(payload) - except (json.JSONDecodeError, UnicodeDecodeError) as error: - raise ProposalError( - f"GitHub API GET {path} returned invalid JSON" - ) from error - if not isinstance(decoded, dict): - raise ProposalError(f"GitHub API GET {path} did not return an object") - return decoded - - -def require_string(record: dict[str, Any], key: str, context: str) -> str: - value = record.get(key) - if not isinstance(value, str) or not value: - raise ProposalError(f"{context} has invalid {key}") - return value - - -def require_integer(record: dict[str, Any], key: str, context: str) -> int: - value = record.get(key) - if not isinstance(value, int) or isinstance(value, bool): - raise ProposalError(f"{context} has invalid {key}") - return value - - -def validate_candidate(reader: GitHubReader, proposed_sha: str) -> int: - repository = reader.get_object(f"/repos/{ORGANIZATION}/{SOURCE_REPOSITORY}") - repository_id = require_integer(repository, "id", "source repository") - if require_string(repository, "default_branch", "source repository") != "main": - raise ProposalError("Source repository default branch is not main") - commit = reader.get_object( - f"/repos/{ORGANIZATION}/{SOURCE_REPOSITORY}/commits/{proposed_sha}" - ) - if require_string(commit, "sha", "candidate commit") != proposed_sha: - raise ProposalError("Candidate commit SHA mismatch") - comparison = reader.get_object( - f"/repos/{ORGANIZATION}/{SOURCE_REPOSITORY}/compare/{proposed_sha}...main" - ) - if comparison.get("status") not in {"ahead", "identical"}: - raise ProposalError("Candidate SHA is not reachable from github-actions main") - return repository_id - - -def validate_canary( - reader: GitHubReader, canary_pr: int, proposed_sha: str -) -> dict[str, object]: - repository_path = f"/repos/{ORGANIZATION}/{CANARY_REPOSITORY}" - pull = reader.get_object(f"{repository_path}/pulls/{canary_pr}") - state = pull.get("state") - merged_at = pull.get("merged_at") - if state != "open" and not (state == "closed" and isinstance(merged_at, str)): - raise ProposalError("Canary pull request is neither open nor merged") - base = pull.get("base") - head = pull.get("head") - if ( - not isinstance(base, dict) - or base.get("ref") != "main" - or not isinstance(head, dict) - ): - raise ProposalError("Canary pull request has an invalid base or head") - head_sha = require_string(head, "sha", "canary pull request head") - if head_sha != proposed_sha: - raise ProposalError("Canary pull request head does not match the proposed SHA") - checks = reader.get_object( - f"{repository_path}/commits/{head_sha}/check-runs?filter=latest&per_page=100" - ) - check_runs = checks.get("check_runs") - if not isinstance(check_runs, list): - raise ProposalError("Canary check-runs response is invalid") - matching = [ - check - for check in check_runs - if isinstance(check, dict) - and check.get("name") == CANARY_CHECK - and isinstance(check.get("app"), dict) - and check["app"].get("slug") == "github-actions" - ] - if len(matching) != 1: - raise ProposalError(f"Expected one canary check, found {len(matching)}") - check = matching[0] - if check.get("status") != "completed" or check.get("conclusion") != "success": - raise ProposalError("Canary check is not successful") - check_id = require_integer(check, "id", "canary check") - details_url = require_string(check, "details_url", "canary check") - run_match = re.search(r"/actions/runs/(\d+)(?:/|$)", details_url) - if run_match is None: - raise ProposalError("Canary check has no workflow run URL") - run_id = int(run_match.group(1)) - run = reader.get_object(f"{repository_path}/actions/runs/{run_id}") - if run.get("head_sha") != head_sha or run.get("path") != CANARY_WORKFLOW: - raise ProposalError( - "Canary workflow run does not match the candidate head or path" - ) - if run.get("conclusion") != "success" or run.get("status") != "completed": - raise ProposalError("Canary workflow run is not successful") - return { - "repository": f"{ORGANIZATION}/{CANARY_REPOSITORY}", - "pull_request": canary_pr, - "head_sha": head_sha, - "check_name": CANARY_CHECK, - "check_run_id": check_id, - "workflow_run_id": run_id, - "workflow_path": CANARY_WORKFLOW, - "conclusion": "success", - } - - -def validate_effective_workflow(ruleset: dict[str, Any], repository_id: int) -> None: - if ruleset.get("id") != RULESET_ID: - raise ProposalError("Effective Ruleset ID mismatch") - if ( - ruleset.get("source_type") != "Organization" - or ruleset.get("source") != ORGANIZATION - ): - raise ProposalError("Effective Ruleset authority mismatch") - if ruleset.get("enforcement") != "active": - raise ProposalError("Effective Ruleset is not active") - rules = ruleset.get("rules") - if not isinstance(rules, list): - raise ProposalError("Effective Ruleset has invalid rules") - matching: list[dict[str, Any]] = [] - for rule in rules: - if not isinstance(rule, dict) or rule.get("type") != "workflows": - continue - parameters = rule.get("parameters") - workflows = ( - parameters.get("workflows") if isinstance(parameters, dict) else None - ) - if not isinstance(workflows, list): - raise ProposalError("Effective workflow rule has invalid workflows") - for workflow in workflows: - if not isinstance(workflow, dict): - raise ProposalError("Effective Ruleset has an invalid workflow entry") - if ( - workflow.get("repository_id") == repository_id - and workflow.get("path") == TARGET_WORKFLOW - and workflow.get("ref") == "refs/heads/main" - ): - matching.append(workflow) - if len(matching) != 1: - raise ProposalError(f"Expected one effective workflow, found {len(matching)}") - - -def canonical_json(value: object) -> str: - return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) - - -def build_proposal( - repository_id: int, - proposed_sha: str, - canary: dict[str, object], -) -> dict[str, object]: - unsigned: dict[str, object] = { - "schema_version": 1, - "operation": "ruleset-workflow-pin", - "target": { - "organization": ORGANIZATION, - "ruleset_id": RULESET_ID, - "workflow": { - "repository_id": repository_id, - "path": TARGET_WORKFLOW, - "ref": "refs/heads/main", - }, - }, - "change": {"proposed_sha": proposed_sha}, - "canary": canary, - } - digest = hashlib.sha256(canonical_json(unsigned).encode()).hexdigest() - return {**unsigned, "digest": f"sha256:{digest}"} - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--proposed-sha", required=True) - parser.add_argument("--canary-pr", type=int, required=True) - parser.add_argument("--api-base-url", default="https://api.github.com") - args = parser.parse_args() - if not re.fullmatch(r"[0-9a-f]{40}", args.proposed_sha): - raise ProposalError( - "--proposed-sha must be a 40-character lowercase commit SHA" - ) - if args.canary_pr < 1: - raise ProposalError("--canary-pr must be positive") - token = os.environ.get("GH_TOKEN") - if not token: - raise ProposalError("GH_TOKEN is required") - - reader = GitHubReader(args.api_base_url, token) - repository_id = validate_candidate(reader, args.proposed_sha) - canary = validate_canary(reader, args.canary_pr, args.proposed_sha) - ruleset = reader.get_object( - f"/repos/{ORGANIZATION}/{EFFECTIVE_REPOSITORY}/rulesets/{RULESET_ID}" - ) - validate_effective_workflow(ruleset, repository_id) - proposal = build_proposal(repository_id, args.proposed_sha, canary) - print(json.dumps(proposal, ensure_ascii=False, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except ProposalError as error: - print(f"error: {error}", file=sys.stderr) - raise SystemExit(1) from error diff --git a/workflow-templates/node-ci.yml b/workflow-templates/node-ci.yml index fc17ad2..87b7bca 100644 --- a/workflow-templates/node-ci.yml +++ b/workflow-templates/node-ci.yml @@ -1,6 +1,6 @@ name: CI -# Thin caller — logic lives in agiletec-inc/.github reusables. +# Thin caller — logic lives in agiletec-inc/github-actions reusables. # Produces required checks: "secret-scan / scan" and "ci / ci". # For PRIVATE repos add `with: { runs-on: agiletec-self-hosted-runner }` to each job. on: @@ -13,8 +13,8 @@ concurrency: jobs: secret-scan: - uses: agiletec-inc/.github/.github/workflows/secret-scan.yml@main + uses: agiletec-inc/github-actions/.github/workflows/secret-scan.yml@main ci: # Opinionated baseline: lint + typecheck + test + build + audit (zero config). # Disable a gate with e.g. `with: { build: false }`. - uses: agiletec-inc/.github/.github/workflows/node-pnpm-ci.yml@main + uses: agiletec-inc/github-actions/.github/workflows/node-pnpm-ci.yml@main diff --git a/workflow-templates/python-ci.yml b/workflow-templates/python-ci.yml index a19d4d4..556fd17 100644 --- a/workflow-templates/python-ci.yml +++ b/workflow-templates/python-ci.yml @@ -1,6 +1,6 @@ name: CI -# Thin caller — logic lives in agiletec-inc/.github reusables. +# Thin caller — logic lives in agiletec-inc/github-actions reusables. # Produces required checks: "secret-scan / scan" and "ci / ci". # `tool: auto` picks uv when uv.lock exists, else pip. # For PRIVATE repos add `with: { runs-on: agiletec-self-hosted-runner }`. @@ -14,8 +14,8 @@ concurrency: jobs: secret-scan: - uses: agiletec-inc/.github/.github/workflows/secret-scan.yml@main + uses: agiletec-inc/github-actions/.github/workflows/secret-scan.yml@main ci: - uses: agiletec-inc/.github/.github/workflows/python-ci.yml@main + uses: agiletec-inc/github-actions/.github/workflows/python-ci.yml@main with: tool: auto diff --git a/workflow-templates/quality-gate.yml b/workflow-templates/quality-gate.yml index a94408c..d22e28b 100644 --- a/workflow-templates/quality-gate.yml +++ b/workflow-templates/quality-gate.yml @@ -13,7 +13,7 @@ concurrency: jobs: ci: name: ci - uses: agiletec-inc/.github/.github/workflows/quality-gate.yml@main + uses: agiletec-inc/github-actions/.github/workflows/quality-gate.yml@main with: linux-runs-on: ${{ github.repository_visibility == 'private' && 'agiletec-self-hosted-runner' || 'ubuntu-latest' }} swift-runs-on: ${{ github.repository_visibility == 'private' && 'agiletec-self-hosted-runner' || 'macos-latest' }} diff --git a/workflow-templates/rust-ci.yml b/workflow-templates/rust-ci.yml index a115be6..af1046a 100644 --- a/workflow-templates/rust-ci.yml +++ b/workflow-templates/rust-ci.yml @@ -1,6 +1,6 @@ name: CI -# Thin caller — logic lives in agiletec-inc/.github reusables. +# Thin caller — logic lives in agiletec-inc/github-actions reusables. # Produces required checks: "secret-scan / scan" and "ci / ci". # For PRIVATE repos add `with: { runs-on: agiletec-self-hosted-runner }` to each job. on: @@ -13,6 +13,6 @@ concurrency: jobs: secret-scan: - uses: agiletec-inc/.github/.github/workflows/secret-scan.yml@main + uses: agiletec-inc/github-actions/.github/workflows/secret-scan.yml@main ci: - uses: agiletec-inc/.github/.github/workflows/rust-cargo-ci.yml@main + uses: agiletec-inc/github-actions/.github/workflows/rust-cargo-ci.yml@main diff --git a/workflow-templates/swift-ci.yml b/workflow-templates/swift-ci.yml index 39e0246..a959bd5 100644 --- a/workflow-templates/swift-ci.yml +++ b/workflow-templates/swift-ci.yml @@ -1,6 +1,6 @@ name: CI -# Thin caller — logic lives in agiletec-inc/.github reusables. +# Thin caller — logic lives in agiletec-inc/github-actions reusables. # Produces required checks: "secret-scan / scan" and "ci / ci". # Swift CI needs macOS; public repos get hosted macOS free. on: @@ -13,6 +13,6 @@ concurrency: jobs: secret-scan: - uses: agiletec-inc/.github/.github/workflows/secret-scan.yml@main + uses: agiletec-inc/github-actions/.github/workflows/secret-scan.yml@main ci: - uses: agiletec-inc/.github/.github/workflows/swift-ci.yml@main + uses: agiletec-inc/github-actions/.github/workflows/swift-ci.yml@main