Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 13 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
coverage/
*.log
.DS_Store
.deps/
17 changes: 17 additions & 0 deletions ops/ci/README.md
Original file line number Diff line number Diff line change
@@ -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 <SHA>`: Safely deploys tested release to `/opt/opsle-components/agent-trajectory-profiler`
- `ops/ci/verify <SHA>`: 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
54 changes: 54 additions & 0 deletions ops/ci/deploy
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail

# Fail-closed deployment for agent-trajectory-profiler
# Usage: ops/ci/deploy <SHA>

sha="${1:-}"
if [[ -z "$sha" ]]; then
echo "usage: $0 <SHA>" >&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" <<EOF
{
"component": "agent-trajectory-profiler",
"sha": "$sha",
"deployed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"deployer": "${USER:-unknown}",
"status": "deployed",
"path": "$target_dir"
}
EOF

echo "deploy: successfully deployed $sha to $target_dir"
92 changes: 92 additions & 0 deletions ops/ci/install-deploy-runner
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# One-time OPERATOR gate (run as root on the VPS host): install the deploy
# runner used only by .github/workflows/deploy.yml.
# sudo bash ops/ci/install-deploy-runner < registration-token-file
set -Eeuo pipefail

readonly account=agent-trajectory-deployer
readonly home=/var/lib/agent-trajectory-deployer
readonly runner_dir="$home/runner"
readonly support=/usr/local/lib/agent-trajectory-deploy-runner
readonly release_root=/opt/opsle-components/agent-trajectory-profiler
readonly repo=opsle/agent-trajectory-profiler
readonly repo_url="https://github.com/$repo"
readonly unit=agent-trajectory-deploy-runner.service
readonly version=2.336.0
readonly archive_sha=04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d
readonly archive_url="https://github.com/actions/runner/releases/download/v$version/actions-runner-linux-x64-$version.tar.gz"

[[ $EUID -eq 0 ]] || { echo "Run as root." >&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" <<UNIT
[Unit]
Description=Agent Trajectory Profiler deploy runner (deploy.yml on main only)
After=network-online.target
Wants=network-online.target

[Service]
User=$account
Group=$account
WorkingDirectory=$runner_dir
ExecStart=$runner_dir/bin/Runner.Listener run --once
Restart=always
RestartSec=10s
Environment=HOME=$home
Environment=ACTIONS_RUNNER_HOOK_JOB_STARTED=$support/job-started.sh
Environment=ACTIONS_RUNNER_HOOK_JOB_COMPLETED=$support/job-completed.sh
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$home /opt/opsle-components/agent-trajectory-profiler
InaccessiblePaths=-/home/deploy -/root -/var/run/docker.sock
UMask=0077

[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now "$unit"
echo "Installed $unit (label agent-trajectory-profiler-deploy)."
90 changes: 90 additions & 0 deletions ops/ci/install-test-runner
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# One-time OPERATOR gate (run as root on the VPS host): install the repo-scoped
# TEST runner used by .github/workflows/ci.yml.
# sudo bash ops/ci/install-test-runner < registration-token-file
set -Eeuo pipefail

readonly account=agent-trajectory-profiler-ci
readonly home=/var/lib/agent-trajectory-profiler-ci
readonly runner_dir="$home/runner"
readonly support=/usr/local/lib/agent-trajectory-profiler-ci-runner
readonly repo_url=https://github.com/opsle/agent-trajectory-profiler
readonly unit=agent-trajectory-profiler-ci-runner.service
readonly version=2.336.0
readonly archive_sha=04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d
readonly archive_url="https://github.com/actions/runner/releases/download/v$version/actions-runner-linux-x64-$version.tar.gz"

[[ $EUID -eq 0 ]] || { echo "Run as root." >&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" <<UNIT
[Unit]
Description=Agent Trajectory Profiler TEST runner (ci.yml)
After=network-online.target
Wants=network-online.target

[Service]
User=$account
Group=$account
WorkingDirectory=$runner_dir
ExecStart=$runner_dir/bin/Runner.Listener run --once
Restart=always
RestartSec=10s
Environment=HOME=$home
Environment=CI=true
Environment=RUNNER_TOOL_CACHE=$home/cache/tool
Environment=ACTIONS_RUNNER_HOOK_JOB_STARTED=$support/job-started.sh
Environment=ACTIONS_RUNNER_HOOK_JOB_COMPLETED=$support/job-completed.sh
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$home
InaccessiblePaths=-/home/deploy -/root -/var/run/docker.sock
CapabilityBoundingSet=
RestrictSUIDSGID=true
UMask=0077

[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now "$unit"
echo "Installed $unit (labels platform-ci,agent-trajectory-profiler)."
33 changes: 33 additions & 0 deletions ops/ci/operator-gate
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Operator helper for the root-only gates for opsle/agent-trajectory-profiler.
# bash ops/ci/operator-gate test install the TEST runner
# bash ops/ci/operator-gate deploy install the deploy runner
set -Eeuo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."

readonly repo=opsle/agent-trajectory-profiler

token() { gh api -X POST "repos/$repo/actions/runners/registration-token" --jq .token; }
online() {
local _ found
for _ in {1..30}; do
found="$(gh api "repos/$repo/actions/runners" --jq ".runners[] | select(.name==\"$1\" and .status==\"online\") | .name")"
if [[ -n $found ]]; then echo "runner $1 is online"; return 0; fi
sleep 2
done
echo "runner $1 did not come online" >&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
Loading