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
233 changes: 233 additions & 0 deletions .github/workflows/distribution-acceptance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
name: Distribution Acceptance

on:
workflow_dispatch:
inputs:
image_tag:
description: "GroundControl image tag to install"
required: false
default: "latest"
workflow_run:
workflows: ["Build & Deploy"]
types: [completed]

permissions:
contents: read
packages: read

jobs:
clean-host:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TARGET_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}

steps:
- name: Checkout tested revision
uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_SHA }}
fetch-depth: 2

- name: Decide whether distribution changed
id: scope
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi

changed="$(git diff-tree --no-commit-id --name-only -r HEAD)"
printf '%s\n' "$changed"

if printf '%s\n' "$changed" | grep -Eq '^(scripts/install|scripts/install\.sha256|scripts/create-install-claim\.cjs|docker-entrypoint\.sh|scripts/ensure-local-vps\.cjs|Dockerfile|docker-compose\.yml|src/lib/install-claim\.ts|src/app/api/auth/claim/route\.ts|src/lib/instance-publish\.ts|src/app/api/instance/publish/route\.ts|\.github/workflows/distribution-acceptance\.yml)$'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi

- name: Select image
if: steps.scope.outputs.run == 'true'
id: image
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_run" ]; then
tag="$(git rev-parse --short=7 HEAD)"
else
tag="${{ inputs.image_tag }}"
tag="${tag:-latest}"
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Testing image tag: $tag"

- name: Authenticate Docker to GHCR
if: steps.scope.outputs.run == 'true'
shell: bash
run: |
set -euo pipefail
printf '%s' "${{ secrets.GITHUB_TOKEN }}" | sudo docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Verify installer integrity
if: steps.scope.outputs.run == 'true'
shell: bash
run: |
set -euo pipefail
bash -n scripts/install
(cd scripts && sha256sum -c install.sha256)

- name: Install, claim, verify and upgrade on clean host
if: steps.scope.outputs.run == 'true'
id: acceptance
shell: bash
env:
IMAGE_TAG: ${{ steps.image.outputs.tag }}
run: |
set -euo pipefail

INSTALL_JSON=/tmp/gc-install.json
CLAIM_JSON=/tmp/gc-claim.json
CLAIM_STATUS=/tmp/gc-claim-status.json
VERIFY_JSON=/tmp/gc-verify.json
RERUN_JSON=/tmp/gc-rerun.json
PREVIEW_JSON=/tmp/gc-preview.json
UPGRADE_JSON=/tmp/gc-upgrade.json
UNINSTALL_JSON=/tmp/gc-uninstall.json
COOKIE_JAR=/tmp/gc-cookies.txt
EVIDENCE_DIR=/tmp/gc-acceptance-evidence
mkdir -p "$EVIDENCE_DIR"

cleanup() {
rm -f "$INSTALL_JSON" "$COOKIE_JAR" /tmp/gc-claim-payload.json
sudo docker logout ghcr.io >/dev/null 2>&1 || true
}
trap cleanup EXIT

sudo bash scripts/install --version "$IMAGE_TAG" --json > "$INSTALL_JSON"
jq -e '
.product == "groundcontrol" and
.stage == "claim_required" and
.next == "human_claim" and
(.localUrl | startswith("http://127.0.0.1:")) and
(.claimUrl | startswith("http://127.0.0.1:")) and
.checks.container == "ready" and
.checks.health == "ready"
' "$INSTALL_JSON" >/dev/null

LOCAL_URL="$(jq -r '.localUrl' "$INSTALL_JSON")"
CLAIM_TOKEN="$(jq -r '.claimToken' "$INSTALL_JSON")"
test -n "$CLAIM_TOKEN"
test "$CLAIM_TOKEN" != "null"

PORT_LINE="$(sudo docker port groundcontrol-web 3000/tcp | head -n 1)"
printf '%s\n' "$PORT_LINE" | grep -Eq '^127\.0\.0\.1:[0-9]+$'
if printf '%s\n' "$PORT_LINE" | grep -Eq '(^|:)0\.0\.0\.0:'; then
echo "GroundControl management port is globally bound" >&2
exit 1
fi

if sudo docker inspect groundcontrol-web --format '{{range .Mounts}}{{println .Destination}}{{end}}' | grep -q '^/root/.ssh$'; then
echo "Canonical install mounted /root/.ssh" >&2
exit 1
fi

ADMIN_PASSWORD="GcAcceptance!Aa1$(openssl rand -hex 10)"
jq -n --arg token "$CLAIM_TOKEN" --arg username "acceptance-admin" --arg password "$ADMIN_PASSWORD" '{token:$token,username:$username,password:$password}' > /tmp/gc-claim-payload.json

curl --fail-with-body -sS -c "$COOKIE_JAR" -H 'Content-Type: application/json' --data-binary @/tmp/gc-claim-payload.json "$LOCAL_URL/api/auth/claim" > "$CLAIM_JSON"

jq -e '.ok == true and .user.role == "admin"' "$CLAIM_JSON" >/dev/null

curl --fail-with-body -sS "$LOCAL_URL/api/auth/claim" > "$CLAIM_STATUS"
jq -e '.claimed == true and .claimRequired == false and .activeClaim == null' "$CLAIM_STATUS" >/dev/null

curl --fail-with-body -sS -b "$COOKIE_JAR" -H 'Content-Type: application/json' --data '{"action":"private"}' "$LOCAL_URL/api/instance/publish" > "$VERIFY_JSON"

jq -e '
.verification.ok == true and
.verification.checks.container.status == "ready" and
.verification.checks.storage.status == "ready" and
.verification.checks.hostExecution.status == "ready" and
.verification.checks.terminalPty.status == "ready" and
.verification.checks.mcpDiscovery.status == "ready" and
.verification.checks.oauthMetadata.status == "ready" and
.verification.checks.claimBoundary.status == "ready" and
.verification.checks.publicHttps.status == "skipped"
' "$VERIFY_JSON" >/dev/null

MCP_TOOLS="$(curl --fail-with-body -sS -X POST "$LOCAL_URL/mcp" -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":"acceptance-tools","method":"tools/list","params":{}}')"
printf '%s' "$MCP_TOOLS" | jq -e '.result.tools | map(.name) | index("deployment.list") != null' >/dev/null

curl --fail-with-body -sS "$LOCAL_URL/.well-known/oauth-protected-resource" | jq -e '.resource | endswith("/mcp")' >/dev/null

sudo bash scripts/install --version "$IMAGE_TAG" --json > "$RERUN_JSON"
jq -e '.stage == "already_claimed" and .changed == false' "$RERUN_JSON" >/dev/null

sudo bash scripts/install --preview --version "$IMAGE_TAG" --json > "$PREVIEW_JSON"
jq -e '
.stage == "upgrade_preview" and
.changed == false and
.checks.container == "ready" and
.checks.storage == "ready" and
.checks.targetImage == "ready"
' "$PREVIEW_JSON" >/dev/null

VOLUME_NAME="$(sudo docker inspect groundcontrol-web --format '{{range .Mounts}}{{if eq .Destination "/app/prisma"}}{{.Name}}{{end}}{{end}}')"
test -n "$VOLUME_NAME"

sudo bash scripts/install --upgrade --version "$IMAGE_TAG" --json > "$UPGRADE_JSON"
jq -e '
.stage == "upgrade_complete" and
.changed == true and
.checks.container == "ready" and
.checks.health == "ready" and
.checks.storageBackup == "ready"
' "$UPGRADE_JSON" >/dev/null

BACKUP_DIR="$(jq -r '.backup' "$UPGRADE_JSON")"
sudo test -f "$BACKUP_DIR/result.json"
sudo cat "$BACKUP_DIR/result.json" | jq -e '.status == "upgrade_complete"' >/dev/null

RUNNING_IMAGE="$(sudo docker inspect groundcontrol-web --format '{{.Config.Image}}')"
if ! printf '%s' "$RUNNING_IMAGE" | grep -q '@sha256:'; then
echo "Upgraded canonical Compose is not pinned to a registry digest: $RUNNING_IMAGE" >&2
exit 1
fi

curl --fail-with-body -sS -b "$COOKIE_JAR" "$LOCAL_URL/api/auth/me" | jq -e '.username == "acceptance-admin" and .role == "admin"' >/dev/null

sudo bash scripts/install --uninstall --json > "$UNINSTALL_JSON"
jq -e '.stage == "uninstalled" and .dataPreserved == true' "$UNINSTALL_JSON" >/dev/null
sudo docker volume inspect "$VOLUME_NAME" >/dev/null

jq 'del(.claimToken,.claimUrl,.claimPath)' "$INSTALL_JSON" > "$EVIDENCE_DIR/install.json"
cp "$CLAIM_JSON" "$EVIDENCE_DIR/claim.json"
cp "$CLAIM_STATUS" "$EVIDENCE_DIR/claim-status.json"
cp "$VERIFY_JSON" "$EVIDENCE_DIR/verification.json"
cp "$RERUN_JSON" "$EVIDENCE_DIR/rerun.json"
cp "$PREVIEW_JSON" "$EVIDENCE_DIR/upgrade-preview.json"
cp "$UPGRADE_JSON" "$EVIDENCE_DIR/upgrade.json"
cp "$UNINSTALL_JSON" "$EVIDENCE_DIR/uninstall.json"

sudo docker volume rm "$VOLUME_NAME" >/dev/null 2>&1 || true
sudo rm -rf /opt/groundcontrol

echo "GroundControl clean-host distribution acceptance passed."
echo "evidence_dir=$EVIDENCE_DIR" >> "$GITHUB_OUTPUT"

- name: Upload redacted acceptance evidence
if: always() && steps.scope.outputs.run == 'true' && steps.acceptance.outputs.evidence_dir != ''
uses: actions/upload-artifact@v4
with:
name: distribution-acceptance-${{ env.TARGET_SHA }}
path: ${{ steps.acceptance.outputs.evidence_dir }}
retention-days: 7

- name: Skip note
if: steps.scope.outputs.run != 'true'
run: echo "Distribution surface unchanged in this deployment; clean-host acceptance skipped."
22 changes: 22 additions & 0 deletions docs/agent-assisted-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,25 @@ A guarded upgrade:
An automatic rollback that restores health exits non-zero because the requested upgrade still failed. Agents should report the rollback as successful recovery, not as a successful upgrade.

`--uninstall` is data-preserving by default. It removes the runtime but leaves the database volume and install directory available for recovery.


## Clean-host acceptance

`.github/workflows/distribution-acceptance.yml` provides a disposable-host acceptance run after relevant production deployments and on manual dispatch.

The workflow proves the canonical distribution contract on a fresh GitHub-hosted Linux machine:

1. verifies the published installer checksum;
2. installs the selected GHCR image through `scripts/install --json`;
3. proves the management port is loopback-only and that the canonical install does not mount `/root/.ssh`;
4. completes the real one-time claim API and creates the first administrator;
5. runs GroundControl's private-mode post-install verification;
6. checks PTY, host execution, MCP discovery, OAuth metadata, persistent storage and claim consumption;
7. reruns the installer to prove idempotency;
8. previews and performs a guarded same-image upgrade, including DB backup evidence and digest-pinned runtime;
9. proves the operator session survives the upgrade;
10. runs the data-preserving uninstall and verifies the database volume remains.

Only redacted evidence is uploaded. Claim tokens and claim URLs are removed before artifacts are created.

External HTTPS/Cloudflare paths remain provider-dependent integration checks; the deterministic clean-host job validates all local trust boundaries without relying on a third-party tunnel during CI.
55 changes: 55 additions & 0 deletions src/lib/distribution-acceptance-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parse } from "yaml";
import { describe, expect, it } from "vitest";

const path = join(process.cwd(), ".github", "workflows", "distribution-acceptance.yml");
const source = readFileSync(path, "utf8");
const workflow = parse(source) as {
name?: string;
on?: Record<string, unknown>;
jobs?: Record<string, {
steps?: Array<{ name?: string; run?: string; uses?: string }>;
}>;
};

describe("distribution acceptance workflow", () => {
it("is valid YAML with manual and post-deploy triggers", () => {
expect(workflow.name).toBe("Distribution Acceptance");
expect(workflow.on).toHaveProperty("workflow_dispatch");
expect(workflow.on).toHaveProperty("workflow_run");
expect(workflow.jobs).toHaveProperty("clean-host");
});

it("exercises the canonical install, human claim, verification, upgrade and uninstall path", () => {
const steps = workflow.jobs?.["clean-host"]?.steps || [];
const script = steps.map((step) => step.run || "").join("\n");

expect(script).toContain("scripts/install --version");
expect(script).toContain("/api/auth/claim");
expect(script).toContain('/api/instance/publish');
expect(script).toContain('"action":"private"');
expect(script).toContain("scripts/install --preview");
expect(script).toContain("scripts/install --upgrade");
expect(script).toContain("scripts/install --uninstall");
});

it("proves the private control plane remains loopback-only and preserves data on uninstall", () => {
const steps = workflow.jobs?.["clean-host"]?.steps || [];
const script = steps.map((step) => step.run || "").join("\n");

expect(script).toContain("127\\.0\\.0\\.1");
expect(script).toContain("/root/.ssh");
expect(script).toContain(".dataPreserved == true");
expect(script).toContain("docker volume inspect");
});

it("redacts the one-time claim material before uploading evidence", () => {
const steps = workflow.jobs?.["clean-host"]?.steps || [];
const script = steps.map((step) => step.run || "").join("\n");

expect(script).toContain("del(.claimToken,.claimUrl,.claimPath)");
expect(source).toContain("actions/upload-artifact@v4");
});
});
Loading