From fc685fc150061236855dcba2db101144fc8cb8f7 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 7 Aug 2026 01:55:18 +0300 Subject: [PATCH 1/3] chore(installer): sync managed adoption mode --- public/install.sh | 285 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 260 insertions(+), 25 deletions(-) diff --git a/public/install.sh b/public/install.sh index 2827c69a..96ffe08d 100755 --- a/public/install.sh +++ b/public/install.sh @@ -7,8 +7,9 @@ set -e # # Usage: # Install: curl -fsSL https://pilotprotocol.network/install.sh | sh -# Pin a version: curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --version v1.13.9 +# Pin a version: curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --version v1.13.6 # Beta channel: curl -fsSL https://pilotprotocol.network/install.sh | sh -s -- --channel beta +# Managed node: curl -fsSL https://pilotprotocol.network/install.sh | PILOT_ENROLLMENT_TOKEN=... sh -s -- --managed-url https://management.pilotprotocol.network # Uninstall: curl -fsSL https://pilotprotocol.network/install.sh | sh -s uninstall # # Flags: @@ -19,6 +20,12 @@ set -e # never silently falls back to an unverified source build. # --yes / -y Skip the older-version confirmation prompt. # --no-warn Suppress the older-version warning entirely. +# --managed-url +# Install the checksum-pinned core managed runtime, claim +# a one-time hosted identity, and start signed reporting. +# This does not install pilot-mcp or a harness adapter. +# --no-start With --managed-url, install and adopt without starting +# the daemon. The hosted onboarding flow omits this flag. # # Legacy env vars (still honored, lower precedence than flags): # PILOT_RELEASE_TAG=vX.Y.Z Same as --version. @@ -27,6 +34,9 @@ set -e # non-interactive/headless installs (no TTY prompt). # If omitted headless, the daemon auto-synthesizes a # @nodes.pilotprotocol.network identity. +# PILOT_MANAGEMENT_URL=https://management.example +# Same as --managed-url. Requires the one-time +# PILOT_ENROLLMENT_TOKEN on first adoption. # # WHAT THIS SCRIPT DOES (read before piping to sh): # 1. Detects OS/arch (Linux/Darwin × amd64/arm64) @@ -89,6 +99,7 @@ REGISTRY="${PILOT_REGISTRY:-34.71.57.205:9000}" BEACON="${PILOT_BEACON:-34.71.57.205:9001}" PILOT_DIR="$HOME/.pilot" BIN_DIR="$PILOT_DIR/bin" +MANAGED_CONTROL_PATH="$PILOT_DIR/managed/enterprise-control.json" # validate_safe LABEL VALUE EXTRA — abort if VALUE contains any character # outside [A-Za-z0-9] plus the punctuation in EXTRA. These values are @@ -131,6 +142,8 @@ PILOT_REQUESTED_VERSION="" PILOT_REQUESTED_CHANNEL="" PILOT_YES=0 PILOT_NO_WARN=0 +PILOT_MANAGED_NO_START=0 +PILOT_MANAGEMENT_URL="${PILOT_MANAGEMENT_URL:-}" PILOT_POSITIONAL="" while [ $# -gt 0 ]; do @@ -149,8 +162,15 @@ while [ $# -gt 0 ]; do PILOT_YES=1; shift ;; --no-warn) PILOT_NO_WARN=1; shift ;; + --managed-url|--management-url) + if [ $# -lt 2 ]; then echo "Error: $1 requires an HTTPS origin" >&2; exit 2; fi + PILOT_MANAGEMENT_URL="$2"; shift 2 ;; + --managed-url=*|--management-url=*) + PILOT_MANAGEMENT_URL="${1#*=}"; shift ;; + --no-start) + PILOT_MANAGED_NO_START=1; shift ;; -h|--help) - sed -n '4,21p' "$0" 2>/dev/null || echo "See https://pilotprotocol.network/install.sh" + sed -n '4,32p' "$0" 2>/dev/null || echo "See https://pilotprotocol.network/install.sh" exit 0 ;; --) shift @@ -165,6 +185,70 @@ while [ $# -gt 0 ]; do esac done +PILOT_MANAGED_MODE=0 +MANAGED_TOKEN="" +if [ -n "$PILOT_MANAGEMENT_URL" ]; then + PILOT_MANAGED_MODE=1 + # Accept a cosmetic trailing slash, but require an HTTPS origin with no + # credentials, path, query, or fragment. The value later becomes both a + # manifest URL and the enrollment authority endpoint. + PILOT_MANAGEMENT_URL="${PILOT_MANAGEMENT_URL%/}" + case "$PILOT_MANAGEMENT_URL" in + https://*) ;; + *) echo "Error: --managed-url must be an HTTPS origin." >&2; exit 2 ;; + esac + _managed_host="${PILOT_MANAGEMENT_URL#https://}" + case "$_managed_host" in + ""|*/*|*@*|*\?*|*\#*) + echo "Error: --managed-url must not contain credentials, a path, query, or fragment." >&2 + exit 2 ;; + esac + validate_safe "management host" "$_managed_host" ".:-" + + if [ -n "$PILOT_POSITIONAL" ]; then + echo "Error: --managed-url cannot be combined with an install/uninstall positional command." >&2 + exit 2 + fi + if [ -n "$PILOT_REQUESTED_VERSION" ] || [ -n "${PILOT_RELEASE_TAG:-}" ] \ + || [ -n "$PILOT_REQUESTED_CHANNEL" ] || [ "${PILOT_RC:-}" = "1" ]; then + echo "Error: managed adoption uses the runtime pinned by the management authority; do not combine it with --version or --channel." >&2 + exit 2 + fi + + MANAGED_TOKEN="${PILOT_ENROLLMENT_TOKEN:-}" + # Do not let the bearer secret reach curl, tar, service managers, or any + # other child. It is exported only to the single pilotctl claim process. + unset PILOT_ENROLLMENT_TOKEN + if [ -e "$MANAGED_CONTROL_PATH" ]; then + if [ -L "$MANAGED_CONTROL_PATH" ] || [ ! -f "$MANAGED_CONTROL_PATH" ]; then + echo "Error: the existing managed control attachment is not a regular file." >&2 + exit 1 + fi + if [ -n "$MANAGED_TOKEN" ]; then + echo "Error: this node is already managed; refusing to consume a new enrollment token." >&2 + echo " Re-run without PILOT_ENROLLMENT_TOKEN to repair or update the managed runtime." >&2 + exit 1 + fi + else + if [ -z "$MANAGED_TOKEN" ]; then + echo "Error: PILOT_ENROLLMENT_TOKEN is required for first managed adoption." >&2 + exit 1 + fi + _managed_token_bytes=$(printf '%s' "$MANAGED_TOKEN" | wc -c | tr -d ' ') + if [ "$_managed_token_bytes" -gt 4096 ] \ + || LC_ALL=C printf '%s' "$MANAGED_TOKEN" | grep -q '[[:cntrl:]]'; then + echo "Error: PILOT_ENROLLMENT_TOKEN is invalid." >&2 + exit 1 + fi + fi + # Managed credentials and state created by this process default owner-only. + umask 077 + MANIFEST_URL="${PILOT_MANAGEMENT_URL}/.well-known/pilot-managed-runtime.json" +elif [ "$PILOT_MANAGED_NO_START" = "1" ]; then + echo "Error: --no-start is only valid with --managed-url." >&2 + exit 2 +fi + # Validate channel value early so we fail fast. `edge` is a back-compat alias # for `beta`: the manifest publishes channels.stable and channels.beta only, so # a literal `edge` lookup would resolve empty and (previously) silently fall @@ -497,6 +581,11 @@ HAVE_MANIFEST=0 if fetch_manifest "$MANIFEST_FILE"; then HAVE_MANIFEST=1 fi +if [ "$PILOT_MANAGED_MODE" = "1" ] && [ "$HAVE_MANIFEST" != "1" ]; then + echo "Error: the management authority did not publish a managed-runtime manifest." >&2 + echo " No binary or enrollment state was changed." >&2 + exit 1 +fi if [ -n "$PILOT_REQUESTED_VERSION" ]; then TAG="$PILOT_REQUESTED_VERSION" @@ -531,6 +620,16 @@ else | tr -d '\r' | head -1) fi +if [ "$PILOT_MANAGED_MODE" = "1" ]; then + case "$TAG" in + managed-runtime-v[0-9]*.[0-9]*.[0-9]*) ;; + *) + echo "Error: the management authority published an invalid managed-runtime version." >&2 + exit 1 ;; + esac + validate_safe "managed runtime version" "$TAG" ".-" +fi + # Fail loudly if the user explicitly asked for a released version/channel but it # resolved to nothing. Silently dropping to the unpinned, UNVERIFIED source # build below would give the user a binary they never asked for, with none of @@ -659,6 +758,11 @@ if [ -n "$TAG" ]; then fi tar -xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" --strip-components=1 else + if [ "$PILOT_MANAGED_MODE" = "1" ]; then + echo "Error: managed runtime ${TAG} could not be downloaded." >&2 + echo " Refusing to fall back to an unmanaged build." >&2 + exit 1 + fi # Archive download failed. Only the automatic default path may fall # back to a source build; an explicit request already hard-failed # above, so reaching here means no version/channel was pinned. @@ -723,6 +827,17 @@ if [ -z "$STAGED_DAEMON" ] || [ -z "$STAGED_CTL" ]; then echo " Nothing was replaced — your existing install is untouched." >&2 exit 1 fi +if [ "$PILOT_MANAGED_MODE" = "1" ]; then + _managed_probe=$(PILOT_ENROLLMENT_TOKEN='' "$STAGED_CTL" --json enterprise adopt --endpoint "$PILOT_MANAGEMENT_URL" 2>&1 || true) + case "$_managed_probe" in + *PILOT_ENROLLMENT_TOKEN*) ;; + *) + echo "Error: ${TAG} does not contain core managed-adoption support." >&2 + echo " Nothing was replaced and the enrollment token was not consumed." >&2 + exit 1 ;; + esac + _managed_probe="" +fi # gateway is optional: extracted to a sibling repo, no longer ships in # release tarballs (release.yml BINS=daemon/pilotctl/updater) and the # source build only runs when ./cmd/gateway is present in the checkout. @@ -777,7 +892,10 @@ if [ "$OS" = "linux" ] && [ "$CAN_PRIV" = true ] \ fi ;; esac if [ -n "$_want" ]; then - RESTART_SYSTEMD="${RESTART_SYSTEMD}${RESTART_SYSTEMD:+ }${_svc}" + if { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$PILOT_MANAGED_NO_START" != "1" ]; } \ + && { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$_svc" != "pilot-updater" ]; }; then + RESTART_SYSTEMD="${RESTART_SYSTEMD}${RESTART_SYSTEMD:+ }${_svc}" + fi # shellcheck disable=SC2086 # $PILOT_SUDO is "" or "sudo" — intentional split $PILOT_SUDO systemctl stop "$_svc" 2>/dev/null || true echo " Stopped ${_svc} (will restart after upgrade)" @@ -788,7 +906,10 @@ if [ "$OS" = "darwin" ]; then for _label in network.pilotprotocol.pilot-daemon network.pilotprotocol.pilot-updater; do _lp="$HOME/Library/LaunchAgents/${_label}.plist" if [ -f "$_lp" ] && launchctl list 2>/dev/null | grep -q "$_label"; then - RESTART_LAUNCHD="${RESTART_LAUNCHD}${RESTART_LAUNCHD:+ }${_label}" + if { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$PILOT_MANAGED_NO_START" != "1" ]; } \ + && { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$_label" != "network.pilotprotocol.pilot-updater" ]; }; then + RESTART_LAUNCHD="${RESTART_LAUNCHD}${RESTART_LAUNCHD:+ }${_label}" + fi launchctl unload "$_lp" 2>/dev/null || true echo " Unloaded ${_label} (will reload after upgrade)" fi @@ -814,6 +935,32 @@ install_bin "$STAGED_CTL" "$BIN_DIR/pilotctl" [ -n "$STAGED_GATEWAY" ] && install_bin "$STAGED_GATEWAY" "$BIN_DIR/pilot-gateway" [ -n "$STAGED_UPDATER" ] && install_bin "$STAGED_UPDATER" "$BIN_DIR/pilot-updater" +# --- Optional hosted adoption (core Pilot, not MCP) --- +# +# The one-time token is exposed only to this process. pilotctl validates the +# delegated key, root pin, trust bundle, bootstrap policy, authority origins, +# and owner-only output before atomically installing ~/.pilot/managed. +MANAGED_ADOPTED=0 +if [ "$PILOT_MANAGED_MODE" = "1" ] && [ ! -e "$MANAGED_CONTROL_PATH" ]; then + if ! _managed_result=$(PILOT_ENROLLMENT_TOKEN="$MANAGED_TOKEN" "$BIN_DIR/pilotctl" --json enterprise adopt --endpoint "$PILOT_MANAGEMENT_URL" 2>&1); then + MANAGED_TOKEN="" + unset MANAGED_TOKEN + echo "Error: the hosted authority did not complete managed adoption." >&2 + printf '%s\n' "$_managed_result" >&2 + exit 1 + fi + MANAGED_TOKEN="" + unset MANAGED_TOKEN + _managed_result="" + if [ -L "$MANAGED_CONTROL_PATH" ] || [ ! -f "$MANAGED_CONTROL_PATH" ]; then + echo "Error: managed adoption returned without installing the verified control attachment." >&2 + exit 1 + fi + MANAGED_ADOPTED=1 +fi +MANAGED_TOKEN="" +unset MANAGED_TOKEN + # --- Symlink into /usr/local/bin so NON-INTERACTIVE shells can find pilotctl --- # # This symlink is the only thing that makes `pilotctl` resolve from a @@ -854,7 +1001,7 @@ if [ "$LINK_OK" = true ]; then # shellcheck disable=SC2086 $LINK_SUDO ln -sf "$BIN_DIR/pilot-gateway" "$LINK_DIR/pilot-gateway" 2>/dev/null || true fi - if [ -f "$BIN_DIR/pilot-updater" ]; then + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then # shellcheck disable=SC2086 $LINK_SUDO ln -sf "$BIN_DIR/pilot-updater" "$LINK_DIR/pilot-updater" 2>/dev/null || true fi @@ -908,7 +1055,13 @@ fi # only when the file is absent so an operator who later runs `pilotctl update # disable` is never silently re-enabled. Turn off any time with # `pilotctl update disable`. -if [ "$UPDATING" != true ] && [ ! -f "$PILOT_DIR/auto-update.json" ]; then +if [ "$PILOT_MANAGED_MODE" = "1" ]; then + # Stable-channel updater builds do not yet carry the managed control + # client. Pin this authority-selected runtime instead of allowing + # a background downgrade to silently remove enforcement. + printf '{\n "enabled": false,\n "reason": "managed-runtime-pinned-by-authority"\n}\n' > "$PILOT_DIR/auto-update.json" + echo "Auto-updates pinned to the management authority runtime channel" +elif [ "$UPDATING" != true ] && [ ! -f "$PILOT_DIR/auto-update.json" ]; then printf '{\n "enabled": true\n}\n' > "$PILOT_DIR/auto-update.json" echo "Auto-updates ENABLED (opt-out) — disable with: pilotctl update disable" fi @@ -919,6 +1072,8 @@ fi # what makes re-running the installer a real repair path: a host whose unit was # written by an older, buggy installer gets a correct one without uninstalling. +MANAGED_START_STYLE="" + if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then if [ "$CAN_PRIV" = true ]; then echo "Setting up systemd service..." @@ -982,7 +1137,7 @@ RestartSec=5 WantedBy=multi-user.target SVC # Auto-updater service - if [ -f "$BIN_DIR/pilot-updater" ]; then + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then # shellcheck disable=SC2086 $PILOT_SUDO tee /etc/systemd/system/pilot-updater.service >/dev/null </dev/null || true + fi echo " Service: pilot-daemon.service" - echo " Service: pilot-updater.service (auto-updates)" + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + echo " Service: pilot-updater.service (auto-updates)" + fi # Auto-enable + start the updater so future releases land without # operator action. The unit file alone is not enough — without this, @@ -1022,7 +1185,7 @@ USVC # operator-tunable flags (-public, -hostname, registry overrides) that the # operator may want to set before first start; on a re-run anything that # was already running is restored below. - if [ -f "$BIN_DIR/pilot-updater" ]; then + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then # shellcheck disable=SC2086 if $PILOT_SUDO systemctl enable --now pilot-updater; then echo " Started: pilot-updater (auto-updates enabled)" @@ -1043,10 +1206,19 @@ USVC fi done - case " $RESTART_SYSTEMD " in - *" pilot-daemon "*) ;; - *) echo " Start daemon: sudo systemctl enable --now pilot-daemon" ;; - esac + if [ "$PILOT_MANAGED_MODE" = "1" ] && [ "$PILOT_MANAGED_NO_START" != "1" ]; then + # Managed onboarding promises a live signed check-in, so unlike an + # ordinary local install it explicitly enables the daemon now. + # shellcheck disable=SC2086 + $PILOT_SUDO systemctl enable --now pilot-daemon + MANAGED_START_STYLE="systemd" + echo " Started: pilot-daemon (managed reporting enabled)" + elif [ "$PILOT_MANAGED_MODE" != "1" ]; then + case " $RESTART_SYSTEMD " in + *" pilot-daemon "*) ;; + *) echo " Start daemon: sudo systemctl enable --now pilot-daemon" ;; + esac + fi else echo " Skipped systemd setup (run as root or with passwordless sudo to enable)" fi @@ -1054,8 +1226,10 @@ elif [ "$OS" = "linux" ]; then # systemd is not the init system here (container / WSL / CI runner). # There is no service to install — tell the agent the portable start path # instead of silently leaving it with no daemon. - echo "No systemd detected (container / WSL / CI) — start the daemon manually:" - echo " pilotctl daemon start" + if [ "$PILOT_MANAGED_MODE" != "1" ]; then + echo "No systemd detected (container / WSL / CI) — start the daemon manually:" + echo " pilotctl daemon start" + fi fi if [ "$OS" = "darwin" ]; then @@ -1138,7 +1312,7 @@ ${EXTRA_ARGS} PLIST # Auto-updater LaunchAgent - if [ -f "$BIN_DIR/pilot-updater" ]; then + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then UPLIST="$PLIST_DIR/network.pilotprotocol.pilot-updater.plist" cat > "$UPLIST" < @@ -1167,7 +1341,15 @@ UPLIST fi echo " Service: network.pilotprotocol.pilot-daemon" - echo " Service: network.pilotprotocol.pilot-updater (auto-updates)" + if [ "$PILOT_MANAGED_MODE" = "1" ]; then + _managed_updater_plist="$PLIST_DIR/network.pilotprotocol.pilot-updater.plist" + if [ -f "$_managed_updater_plist" ]; then + launchctl unload -w "$_managed_updater_plist" 2>/dev/null || true + fi + fi + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + echo " Service: network.pilotprotocol.pilot-updater (auto-updates)" + fi # Auto-load the updater LaunchAgent so future releases land without # operator action. Without this, install.sh writes the plist but leaves @@ -1179,7 +1361,7 @@ UPLIST # idempotent: any stale running agent is replaced cleanly. -w persists # the load across reboots. The daemon is left as opt-in for the same # reason as the Linux branch. - if [ -f "$BIN_DIR/pilot-updater" ] && [ -f "$UPLIST" ]; then + if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ] && [ -f "$UPLIST" ]; then launchctl unload "$UPLIST" 2>/dev/null || true launchctl load -w "$UPLIST" echo " Started: pilot-updater (auto-updates enabled)" @@ -1201,13 +1383,42 @@ UPLIST fi done - case " $RESTART_LAUNCHD " in - *" network.pilotprotocol.pilot-daemon "*) ;; - *) - echo " Start daemon: launchctl load -w $PLIST" - echo " Stop daemon: launchctl unload $PLIST" - ;; - esac + if [ "$PILOT_MANAGED_MODE" != "1" ]; then + case " $RESTART_LAUNCHD " in + *" network.pilotprotocol.pilot-daemon "*) ;; + *) + echo " Start daemon: launchctl load -w $PLIST" + echo " Stop daemon: launchctl unload $PLIST" + ;; + esac + fi +fi + +# A managed Connect Agent run is complete only when the newly adopted core +# daemon is locally responsive. The management console independently waits for +# the signed remote report, so this check cannot forge onboarding success. +if [ "$PILOT_MANAGED_MODE" = "1" ]; then + if [ "$PILOT_MANAGED_NO_START" = "1" ]; then + echo "Managed runtime adopted but not started (--no-start)." + elif [ "$MANAGED_START_STYLE" = "systemd" ]; then + _managed_wait=0 + until "$BIN_DIR/pilotctl" daemon status --check >/dev/null 2>&1; do + _managed_wait=$((_managed_wait + 1)) + if [ "$_managed_wait" -ge 30 ]; then + echo "Error: the managed daemon did not become ready under systemd." >&2 + exit 1 + fi + sleep 1 + done + else + # Portable and launchd installs need an explicit restart so a daemon + # that was already running cannot keep the pre-adoption configuration. + if "$BIN_DIR/pilotctl" daemon status --check >/dev/null 2>&1; then + "$BIN_DIR/pilotctl" daemon stop >/dev/null + fi + "$BIN_DIR/pilotctl" daemon start --wait 30s >/dev/null + "$BIN_DIR/pilotctl" daemon status --check >/dev/null + fi fi # --- Add to PATH --- @@ -1279,6 +1490,30 @@ fi # Write version file for the auto-updater [ -n "$TAG" ] && echo "$TAG" > "$BIN_DIR/.pilot-version" +if [ "$PILOT_MANAGED_MODE" = "1" ]; then + echo "" + echo "Managed Pilot node ready:" + echo " Runtime: ${TAG}" + echo " Authority: ${PILOT_MANAGEMENT_URL}" + echo " Control: ${MANAGED_CONTROL_PATH}" + if [ "$MANAGED_ADOPTED" = "1" ]; then + echo " Enrollment: claimed once and installed" + else + echo " Enrollment: existing managed identity preserved" + fi + if [ "$PILOT_MANAGED_NO_START" = "1" ]; then + echo " Daemon: not started (--no-start)" + else + echo " Daemon: running; signed fleet reporting enabled" + fi + echo " MCP: not installed (optional, separate product)" + echo "" + echo "Harness interception is a separate optional attachment step." + echo "The management console will verify the node's signed report before" + echo "it marks onboarding complete." + exit 0 +fi + # --- Upgrade: short summary, skip the first-run onboarding text --- # # Everything above this point (binary swap, unit/plist regeneration, service From 3b7cb86df0e6491d15ac4d0bce33c0c51dbc216e Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 7 Aug 2026 02:00:04 +0300 Subject: [PATCH 2/3] chore(ci): trigger installer fallback validation From 59995830430dc2713a7657c56aee6a4ba7d6a597 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 7 Aug 2026 02:00:52 +0300 Subject: [PATCH 3/3] ci: allow manual site validation --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 399fef32..761fb235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: ci on: + workflow_dispatch: push: branches: [main] pull_request: