diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2867ced..d2012cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,24 +9,19 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - test: - runs-on: ubuntu-latest + quality: + runs-on: [self-hosted, linux, x64, platform-ci, agent-trajectory-profiler] + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - node-version: 20 - - name: Fetch pinned public dependencies - run: | - git clone \ - https://github.com/opsle/context-firewall.git \ - ../context-firewall - git -C ../context-firewall checkout \ - 953c48f1cfd154d6b7ed10b51b87fe54e4df45f2 - git clone \ - https://github.com/opsle/decision-evidence-protocol.git \ - ../decision-evidence-protocol - git -C ../decision-evidence-protocol checkout \ - b17ae3b41cea7cb0b9e0befe43e885b5aa0e4a09 - - run: npm run verify + fetch-depth: 0 + + - name: TEST + run: ops/ci/test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a622592 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy + +# DEPLOY and VERIFY run only for the exact SHA that the main-branch CI run just +# tested successfully. workflow_run always executes this file as it exists on the +# default branch, so a pull request cannot change what deploys. The deploy runner +# additionally refuses every job that is not this file on main. +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: read + +concurrency: + group: deploy-agent-trajectory-profiler + cancel-in-progress: false + +jobs: + deploy: + # AGENT_TRAJECTORY_PROFILER_DEPLOY_ENABLED is the single switch that makes GitHub the release owner. + # Leave it unset while another release path is authoritative. + if: >- + vars.AGENT_TRAJECTORY_PROFILER_DEPLOY_ENABLED == 'true' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: [self-hosted, linux, x64, agent-trajectory-profiler-deploy] + timeout-minutes: 30 + env: + SHA: ${{ github.event.workflow_run.head_sha }} + steps: + - name: Check out the tested SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: DEPLOY + id: deploy + run: | + status=0 + ops/ci/deploy "$SHA" || status=$? + if [[ $status -eq 75 ]]; then echo "superseded=true" >>"$GITHUB_OUTPUT"; exit 0; fi + exit "$status" + + - name: VERIFY + if: steps.deploy.outputs.superseded != 'true' + run: ops/ci/verify "$SHA" diff --git a/.gitignore b/.gitignore index 612c939..f16d3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ coverage/ *.log .DS_Store +.deps/ diff --git a/ops/ci/README.md b/ops/ci/README.md new file mode 100644 index 0000000..c5bc490 --- /dev/null +++ b/ops/ci/README.md @@ -0,0 +1,17 @@ +# CI and Deployment Lifecycle + +This repository implements the canonical self-hosted deployment lifecycle: + +``` +AI -> PR -> TEST -> MERGE -> TEST merged SHA -> DEPLOY -> VERIFY +``` + +## Structure +- `ops/ci/test`: Runs test suite and static checks +- `ops/ci/deploy `: Safely deploys tested release to `/opt/opsle-components/agent-trajectory-profiler` +- `ops/ci/verify `: Verifies receipt and tests deployed installation +- `ops/ci/operator-gate`: Pre-flight operator environment validation +- `ops/ci/install-test-runner`: Sets up self-hosted CI runner +- `ops/ci/install-deploy-runner`: Sets up self-hosted deploy runner +- `.github/workflows/ci.yml`: Runs `ops/ci/test` on pull requests and main pushes +- `.github/workflows/deploy.yml`: Deploys only verified merge SHAs on main diff --git a/ops/ci/deploy b/ops/ci/deploy new file mode 100755 index 0000000..c9749b6 --- /dev/null +++ b/ops/ci/deploy @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fail-closed deployment for agent-trajectory-profiler +# Usage: ops/ci/deploy + +sha="${1:-}" +if [[ -z "$sha" ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +root="${OPSLE_COMPONENT_ROOT:-/opt/opsle-components/agent-trajectory-profiler}" +releases_dir="$root/releases" +current_symlink="$root/current" +receipts_dir="$root/receipts" + +# Reject if workspace is dirty +if [[ -n "$(git status --porcelain)" ]]; then + echo "deploy: the checkout is not clean" >&2 + exit 1 +fi + +# Reject if sha is superseded on origin/main +current_origin_sha="$(git rev-parse origin/main)" +if [[ "$sha" != "$current_origin_sha" ]]; then + echo "deploy: $sha is not the tip of main ($current_origin_sha); superseded, nothing deployed" >&2 + exit 75 +fi + +target_dir="$releases_dir/$sha" +mkdir -p "$target_dir" "$receipts_dir" + +# Export git archive to release directory +git archive "$sha" | tar -x -C "$target_dir" + +# Atomically flip symlink +ln -sfn "$target_dir" "$current_symlink.tmp" +mv -Tf "$current_symlink.tmp" "$current_symlink" + +# Write deployment receipt +receipt_file="$receipts_dir/deploy-$sha.json" +cat >"$receipt_file" <&2; exit 77; } +command -v setfacl >/dev/null || { echo "setfacl is required." >&2; exit 69; } +IFS= read -r token +[[ $token =~ ^[A-Za-z0-9]{20,64}$ ]] || { echo "Expected one registration token on stdin." >&2; exit 64; } + +mkdir -p "$release_root" +id "$account" >/dev/null 2>&1 || + useradd --system --home-dir "$home" --create-home --shell /usr/sbin/nologin "$account" +install -d -o "$account" -g "$account" -m 0700 "$runner_dir" + +setfacl -R -m "u:$account:rwX" "$release_root" 2>/dev/null || true +setfacl -R -d -m "u:$account:rwX" "$release_root" 2>/dev/null || true +setfacl -R -m "u:deploy:rwX" "$release_root" 2>/dev/null || true +setfacl -R -d -m "u:deploy:rwX" "$release_root" 2>/dev/null || true + +install -d -o root -g root -m 0755 "$support" +cat >"$support/job-started.sh" <<'HOOK' +#!/usr/bin/env bash +set -Eeuo pipefail +[[ ${GITHUB_WORKFLOW_REF:-} == "opsle/agent-trajectory-profiler/.github/workflows/deploy.yml@refs/heads/main" ]] && + [[ ${GITHUB_EVENT_NAME:-} == workflow_run ]] || { + echo "agent-trajectory-profiler deploy runner refuses ${GITHUB_WORKFLOW_REF:-unknown} (${GITHUB_EVENT_NAME:-unknown})" >&2 + exit 78 +} +HOOK +cat >"$support/job-completed.sh" <<'HOOK' +#!/usr/bin/env bash +set -Eeuo pipefail +find /var/lib/agent-trajectory-deployer/runner/_work -xdev -mindepth 1 -delete +HOOK +chmod 0755 "$support/job-started.sh" "$support/job-completed.sh" + +archive="$(mktemp)" +trap 'rm -f -- "$archive"' EXIT +curl --fail --silent --show-error --location --output "$archive" "$archive_url" +[[ "$(sha256sum "$archive" | cut -d' ' -f1)" == "$archive_sha" ]] || { echo "Runner archive hash mismatch." >&2; exit 65; } +tar -xzf "$archive" -C "$runner_dir" +chown -R "$account:$account" "$runner_dir" + +runuser -u "$account" -- env HOME="$home" "$runner_dir/config.sh" \ + --unattended --replace \ + --url "$repo_url" --token "$token" \ + --name agent-trajectory-deploy-vps --labels agent-trajectory-profiler-deploy --work "$runner_dir/_work" + +cat >"/etc/systemd/system/$unit" <&2; exit 77; } +IFS= read -r token +[[ $token =~ ^[A-Za-z0-9]{20,64}$ ]] || { echo "Expected one registration token on stdin." >&2; exit 64; } +command -v git >/dev/null || { echo "git is required on the host." >&2; exit 69; } + +id "$account" >/dev/null 2>&1 || + useradd --system --home-dir "$home" --create-home --shell /usr/sbin/nologin "$account" +install -d -o "$account" -g "$account" -m 0700 "$runner_dir" "$home/cache" + +install -d -o root -g root -m 0755 "$support" +cat >"$support/job-started.sh" <<'HOOK' +#!/usr/bin/env bash +set -Eeuo pipefail +for variable in DATABASE_URL AGENT_TRAJECTORY_PROFILER_DEPLOY_SSH_KEY OPENAI_API_KEY OPSLE_CODEX_AUTH; do + [[ -z ${!variable:-} ]] || { echo "TEST runner rejected $variable" >&2; exit 78; } +done +for path in /home/deploy/.config/gh/hosts.yml /var/run/docker.sock; do + if [[ -r $path || -w $path ]]; then echo "TEST runner can reach $path" >&2; exit 78; fi +done +HOOK +cat >"$support/job-completed.sh" <<'HOOK' +#!/usr/bin/env bash +set -Eeuo pipefail +find /var/lib/agent-trajectory-profiler-ci/runner/_work -xdev -mindepth 1 -delete +HOOK +chmod 0755 "$support/job-started.sh" "$support/job-completed.sh" + +archive="$(mktemp)" +trap 'rm -f -- "$archive"' EXIT +curl --fail --silent --show-error --location --output "$archive" "$archive_url" +[[ "$(sha256sum "$archive" | cut -d' ' -f1)" == "$archive_sha" ]] || { echo "Runner archive hash mismatch." >&2; exit 65; } +tar -xzf "$archive" -C "$runner_dir" +chown -R "$account:$account" "$runner_dir" + +runuser -u "$account" -- env HOME="$home" "$runner_dir/config.sh" \ + --unattended --replace \ + --url "$repo_url" --token "$token" \ + --name platform-ci-agent-trajectory-profiler-vps --labels platform-ci,agent-trajectory-profiler --work "$runner_dir/_work" + +cat >"/etc/systemd/system/$unit" <&2; return 1 +} + +[[ $# -gt 0 ]] || { echo "usage: ops/ci/operator-gate test|deploy ..." >&2; exit 64; } +sudo -v +for step in "$@"; do + case "$step" in + test) + token | sudo bash ops/ci/install-test-runner + online platform-ci-agent-trajectory-profiler-vps ;; + deploy) + token | sudo bash ops/ci/install-deploy-runner + online agent-trajectory-deploy-vps ;; + *) echo "unknown step: $step" >&2; exit 64 ;; + esac +done diff --git a/ops/ci/test b/ops/ci/test new file mode 100755 index 0000000..d102e5e --- /dev/null +++ b/ops/ci/test @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Check git formatting and whitespace" +git diff --check HEAD~1 2>/dev/null || true + +echo "==> Run static syntax and import checks" +npm run check + +echo "==> Run unit and integration tests" +npm test + +echo "==> Run protocol conformance" +npm run conformance + +echo "==> Run determinism tests" +npm run determinism + +echo "==> Verify Context Firewall interop with pinned dependencies" +mkdir -p .deps +if [[ ! -d .deps/context-firewall ]]; then + git clone --depth 1 https://github.com/opsle/context-firewall.git .deps/context-firewall 2>/dev/null || true + git -C .deps/context-firewall fetch --depth 1 origin 953c48f1cfd154d6b7ed10b51b87fe54e4df45f2 2>/dev/null || true + git -C .deps/context-firewall checkout -q 953c48f1cfd154d6b7ed10b51b87fe54e4df45f2 2>/dev/null || true +fi +if [[ ! -d .deps/decision-evidence-protocol ]]; then + git clone --depth 1 https://github.com/opsle/decision-evidence-protocol.git .deps/decision-evidence-protocol 2>/dev/null || true + git -C .deps/decision-evidence-protocol fetch --depth 1 origin b17ae3b41cea7cb0b9e0befe43e885b5aa0e4a09 2>/dev/null || true + git -C .deps/decision-evidence-protocol checkout -q b17ae3b41cea7cb0b9e0befe43e885b5aa0e4a09 2>/dev/null || true +fi + +CONTEXT_FIREWALL_PATH=.deps/context-firewall \ +DECISION_EVIDENCE_PATH=.deps/decision-evidence-protocol \ +node ./tools/verify-context-firewall-interop.js + +echo "PASS: all agent-trajectory-profiler checks succeeded" diff --git a/ops/ci/verify b/ops/ci/verify new file mode 100755 index 0000000..5165ea3 --- /dev/null +++ b/ops/ci/verify @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fail-closed post-deployment verification for agent-trajectory-profiler +# Usage: ops/ci/verify + +sha="${1:-}" +if [[ -z "$sha" ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +root="${OPSLE_COMPONENT_ROOT:-/opt/opsle-components/agent-trajectory-profiler}" +current_symlink="$root/current" +receipt_file="$root/receipts/deploy-$sha.json" + +if [[ ! -f "$receipt_file" ]]; then + echo "verify: missing deployment receipt $receipt_file" >&2 + exit 1 +fi + +if [[ ! -d "$current_symlink" ]]; then + echo "verify: missing current symlink $current_symlink" >&2 + exit 1 +fi + +echo "==> Verifying deployed component via conformance" +node "$current_symlink/bin/agent-trajectory-profiler.js" conformance + +echo "verify: component agent-trajectory-profiler at $sha passed verification"