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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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, research]
timeout-minutes: 20
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
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-research
cancel-in-progress: false

jobs:
deploy:
# RESEARCH_DEPLOY_ENABLED is the single switch that makes GitHub the release owner.
# Leave it unset while another release path is authoritative.
if: >-
vars.RESEARCH_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, research-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"
60 changes: 0 additions & 60 deletions .github/workflows/program-registry.yml

This file was deleted.

20 changes: 20 additions & 0 deletions ops/ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# ops/ci — canonical lifecycle for opsle/research

```
AI → PR → TEST → MERGE → TEST merged SHA → DEPLOY → VERIFY
```

| Command | Runs on | Holds |
| --- | --- | --- |
| `ops/ci/test` | `platform-ci,research` runner (`ci.yml`, job `quality`) | no production credentials |
| `ops/ci/deploy SHA` | `research-deploy` runner (`deploy.yml`) | write access (POSIX ACL) to `/opt/opsle-components/research` |
| `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/research/releases/<sha>` and atomically pointed to by `current`.
- **Single release owner:** `deploy.yml` does nothing unless repository variable `RESEARCH_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.
59 changes: 59 additions & 0 deletions ops/ci/deploy
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/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="research"
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"
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" <<RECEIPT
{
"component": "$component",
"revision": "$sha",
"previous": "${previous##*/}",
"deployed_at": "$(date -u +%FT%TZ)",
"status": "active"
}
RECEIPT

echo "deploy: released $component $sha at $release_root"
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=research-deployer
readonly home=/var/lib/research-deployer
readonly runner_dir="$home/runner"
readonly support=/usr/local/lib/research-deploy-runner
readonly release_root=/opt/opsle-components/research
readonly repo=opsle/research
readonly repo_url="https://github.com/$repo"
readonly unit=research-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/research/.github/workflows/deploy.yml@refs/heads/main" ]] &&
[[ ${GITHUB_EVENT_NAME:-} == workflow_run ]] || {
echo "research 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/research-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 research-deploy-vps --labels research-deploy --work "$runner_dir/_work"

cat >"/etc/systemd/system/$unit" <<UNIT
[Unit]
Description=Research 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/research
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 research-deploy)."
Loading