diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70a8e8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: [self-hosted, linux, x64, platform-ci, visible-value] + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + 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..69b7abc --- /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-visible-value + cancel-in-progress: false + +jobs: + deploy: + # VISIBLE_VALUE_DEPLOY_ENABLED is the single switch that makes GitHub the release owner. + # Leave it unset while another release path is authoritative. + if: >- + vars.VISIBLE_VALUE_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, visible-value-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/ops/ci/README.md b/ops/ci/README.md new file mode 100644 index 0000000..d378c20 --- /dev/null +++ b/ops/ci/README.md @@ -0,0 +1,20 @@ +# ops/ci — canonical lifecycle for opsle/visible-value + +``` +AI → PR → TEST → MERGE → TEST merged SHA → DEPLOY → VERIFY +``` + +| Command | Runs on | Holds | +| --- | --- | --- | +| `ops/ci/test` | `platform-ci,visible-value` runner (`ci.yml`, job `quality`) | no production credentials | +| `ops/ci/deploy SHA` | `visible-value-deploy` runner (`deploy.yml`) | write access (POSIX ACL) to `/opt/opsle-components/visible-value` | +| `ops/ci/verify SHA` | deploy runner, or anywhere | nothing; read-only | + +- **Merge enforcement:** `opsle` is on a free GitHub plan (no rulesets, branch protection or auto-merge). Merge only with `gh pr merge --squash --delete-branch` after `quality` passes. +- **Exact SHA:** `deploy.yml` is a `workflow_run` of the `push`-to-`main` CI run and checks out and deploys `workflow_run.head_sha`. `ops/ci/deploy` refuses anything that is not the tip of main (exit 75 = superseded) or a checkout that is not exactly that SHA. +- **Atomic switch:** releases are deployed under `/opt/opsle-components/visible-value/releases/` and atomically pointed to by `current`. +- **Single release owner:** `deploy.yml` does nothing unless repository variable `VISIBLE_VALUE_DEPLOY_ENABLED` is `true`. + +## Operator gates (root; not doable by agents) + +Run `bash ops/ci/operator-gate test deploy` in a terminal. It mints the registration tokens and installs the self-hosted runners. diff --git a/ops/ci/deploy b/ops/ci/deploy new file mode 100755 index 0000000..779c860 --- /dev/null +++ b/ops/ci/deploy @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Canonical DEPLOY stage: publish one exact merged SHA as a component release. +# ops/ci/deploy SHA run from a clean checkout of exactly SHA +set -Eeuo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +readonly sha="${1:-}" +[[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "usage: ops/ci/deploy SHA" >&2; exit 64; } +readonly component="visible-value" +readonly release_root="${OPSLE_COMPONENT_ROOT:-/opt/opsle-components}/$component" + +fail() { echo "deploy: $1" >&2; exit 1; } + +[[ "$(git rev-parse HEAD)" == "$sha" ]] || fail "the checkout is not exactly $sha" +# Only the current tip of main may deploy; an older merge is simply superseded. +tip="$(git ls-remote origin refs/heads/main | cut -f1)" +if [[ $tip != "$sha" ]]; then + echo "deploy: $sha is not the tip of main ($tip); superseded, nothing deployed" >&2 + exit 75 +fi +[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || fail "the checkout is not clean" + +mkdir -p "$release_root/releases" "$release_root/receipts" +exec 9>"$release_root/.release.lock" +flock -n 9 || fail "another $component release is running" + +release_dir="$release_root/releases/$sha" +previous="$(readlink "$release_root/current" || true)" + +if [[ ! -d $release_dir ]]; then + candidate="$release_root/releases/.candidate-$sha-$$" + trap 'rm -rf -- "$candidate"' EXIT + mkdir -p "$candidate" + git archive "$sha" | tar -x -C "$candidate" + npm --prefix "$candidate" pack --pack-destination "$candidate" 2>/dev/null || true + chmod -R a+rX "$candidate" + mv "$candidate" "$release_dir" + trap - EXIT +fi + +switch() { + ln -sfn "$1" "$release_root/current.next" + mv -Tf "$release_root/current.next" "$release_root/current" +} + +switch "$release_dir" +printf '%s\n' "$previous" >"$release_root/previous-release" + +receipt="$release_root/receipts/deploy-$sha.json" +cat >"$receipt" <&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/visible-value/.github/workflows/deploy.yml@refs/heads/main" ]] && + [[ ${GITHUB_EVENT_NAME:-} == workflow_run ]] || { + echo "visible-value 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/visible-value-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 visible-value-deploy-vps --labels visible-value-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; } +for tool in git node npm; do command -v "$tool" >/dev/null || { echo "$tool is required on the host." >&2; exit 69; }; done + +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 VISIBLE_VALUE_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/visible-value-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-visible-value-vps --labels platform-ci,visible-value --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-visible-value-vps ;; + deploy) + token | sudo bash ops/ci/install-deploy-runner + online visible-value-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..3b2b864 --- /dev/null +++ b/ops/ci/test @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Canonical TEST stage for opsle/visible-value. GitHub Actions and agents run this same command. +# ops/ci/test +set -Eeuo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +step() { printf '\n==> %s\n' "$*"; } + +step "Run test suite" +npm test + +step "Test CLI entry point" +node bin/visible-value.js summarize examples/run.json >/dev/null diff --git a/ops/ci/verify b/ops/ci/verify new file mode 100755 index 0000000..32e6f9d --- /dev/null +++ b/ops/ci/verify @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Canonical VERIFY stage: prove the deployed component is the exact SHA and healthy. +# ops/ci/verify SHA +set -Eeuo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +readonly sha="${1:-}" +[[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "usage: ops/ci/verify SHA" >&2; exit 64; } +readonly component="visible-value" +readonly release_root="${OPSLE_COMPONENT_ROOT:-/opt/opsle-components}/$component" + +fail() { echo "verify: $1" >&2; exit 1; } + +current="$(readlink -f "$release_root/current" || true)" +[[ -d "$current" ]] || fail "current release directory missing: $release_root/current" + +receipt="$release_root/receipts/deploy-$sha.json" +[[ -f "$receipt" ]] || fail "deployment receipt missing: $receipt" + +receipt_sha="$(grep -o '"revision": "[^"]*"' "$receipt" | cut -d'"' -f4)" +[[ "$receipt_sha" == "$sha" ]] || fail "receipt revision mismatch: expected $sha, got $receipt_sha" + +node "$current/bin/visible-value.js" summarize "$current/examples/run.json" >/dev/null + +echo "verify: $component is deployed at $sha and healthy"