diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index cf1d674..093c5ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: Devboxes version - placeholder: v0.1.0 + placeholder: v0.1.1 validations: required: true - type: input diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54075d0..8e02868 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install uv - run: python -m pip install uv==0.11.26 + run: python -m pip install uv==0.11.28 - name: Install dependencies working-directory: controller run: uv sync --frozen --extra dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 364a998..8043e4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,22 @@ permissions: contents: read jobs: + validate: + name: Release metadata + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check tag and synchronized versions + run: | + scripts/check-version.sh + version="${GITHUB_REF_NAME#v}" + chart_version="$(awk '/^version:/ {print $2; exit}' charts/devboxes/Chart.yaml)" + test "$version" = "$chart_version" + grep -Fq "## [$version]" CHANGELOG.md + create-release: name: Create GitHub release + needs: validate runs-on: ubuntu-latest permissions: contents: write @@ -56,6 +70,7 @@ jobs: images: name: ${{ matrix.component }} image + needs: validate strategy: fail-fast: false matrix: @@ -106,6 +121,7 @@ jobs: helm: name: Helm chart + needs: validate runs-on: ubuntu-latest permissions: contents: read @@ -126,3 +142,95 @@ jobs: run: | helm package charts/devboxes --destination /tmp helm push "/tmp/devboxes-${GITHUB_REF_NAME#v}.tgz" oci://ghcr.io/${{ github.repository_owner }}/charts + + published-images: + name: Published images + needs: images + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + attestations: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Verify anonymous pulls, platforms, SBOMs, and provenance + env: + DEVBOXES_VERSION: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + DEVBOXES_VERSION="${DEVBOXES_VERSION#v}" scripts/verify-published-images.sh + + published-chart: + name: Published chart (Helm ${{ matrix.major }}) + needs: helm + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - major: 3 + version: v3.21.3 + - major: 4 + version: v4.2.2 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: ${{ matrix.version }} + - name: Pull and render the public OCI chart + env: + DEVBOXES_VERSION: ${{ github.ref_name }} + run: | + version="${DEVBOXES_VERSION#v}" + helm pull oci://ghcr.io/${{ github.repository_owner }}/charts/devboxes \ + --version "$version" \ + --destination /tmp + helm template devboxes "/tmp/devboxes-$version.tgz" \ + --namespace devboxes \ + --set workspace.sshService.type=NodePort \ + --set workspace.sshService.host=192.0.2.10 \ + --set ingress.enabled=true \ + --set ingress.className=nginx \ + --set ingress.tls.enabled=true \ + --set ingress.tls.secretName=devboxes-tls \ + --set controller.externalUrl=https://devboxes.example.com \ + --set controller.cookieSecure=true \ + --set podDisruptionBudget.enabled=true \ + > /tmp/devboxes-published.yaml + grep -Fq "ghcr.io/${{ github.repository_owner }}/devboxes-controller:$version" /tmp/devboxes-published.yaml + grep -Fq "ghcr.io/${{ github.repository_owner }}/devboxes-workspace:$version" /tmp/devboxes-published.yaml + - name: Strictly validate published Kubernetes resources + run: >- + docker run --rm -i ghcr.io/yannh/kubeconform:v0.7.0 + -strict -summary + < /tmp/devboxes-published.yaml + + published-e2e: + name: Published clean-cluster lifecycle + needs: [cli, published-images, published-chart] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: v4.2.2 + - name: Install kind + run: | + go install sigs.k8s.io/kind@v0.31.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + - name: Run lifecycle through released CLI, chart, and images + env: + DEVBOXES_VERSION: ${{ github.ref_name }} + run: | + DEVBOXES_VERSION="${DEVBOXES_VERSION#v}" scripts/published-e2e.sh + + release-gate: + name: Release gate + needs: published-e2e + runs-on: ubuntu-latest + steps: + - run: echo "Published release artifacts passed every release gate." diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d805e..c3d6b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Devboxes are documented here. The project follows [Keep a ## [Unreleased] +## [0.1.1] - 2026-07-13 + ### Added - Public golden path, CLI and API references, operations runbook, troubleshooting guide, and documentation index. @@ -13,10 +15,13 @@ All notable changes to Devboxes are documented here. The project follows [Keep a - Tightened Rust and Python quality policy, regression tests, and contributor guidance. - Removed internal product and design specifications from the public repository after preserving durable requirements in maintained documentation. +- Updated the workspace to Rust 1.97 and uv 0.11.28, the controller image to uv 0.11.28, the CLI TOML parser to 1.1, and pinned GitHub Actions to their current supported releases. +- Added release gates that install the checksum-verified public CLI, inspect multi-architecture image attestations, render the published OCI chart with Helm 3 and Helm 4, and run the full lifecycle against published artifacts. ### Fixed - Updated pytest to a patched release and expanded CI auditing to cover development dependencies in the Python lockfile. +- Extended the workspace Deployment progress deadline for cold pulls of the prepared image on slower registry paths. ## [0.1.0] - 2026-07-12 @@ -29,5 +34,6 @@ All notable changes to Devboxes are documented here. The project follows [Keep a - Portable Helm chart with values schema, namespace-scoped RBAC, configurable storage, ingress, LoadBalancer or NodePort SSH, ServiceMonitor, and disruption budget. - macOS and Linux CLI releases, SHA-256 verification installer, GHCR images, OCI chart publishing, image provenance attestations, and clean Kind install CI. -[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/vicotrbb/devboxes/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/vicotrbb/devboxes/releases/tag/v0.1.0 diff --git a/README.md b/README.md index f46028c..c5b9f89 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ kubectl -n devboxes create secret generic devboxes-workspace \ --from-file=SSH_AUTHORIZED_KEYS="$HOME/.ssh/id_ed25519.pub" helm install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.1.0 \ + --version 0.1.1 \ --namespace devboxes ``` diff --git a/charts/devboxes/Chart.yaml b/charts/devboxes/Chart.yaml index d1f7298..82e8e5d 100644 --- a/charts/devboxes/Chart.yaml +++ b/charts/devboxes/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: devboxes description: Self-hosted, ephemeral development environments on Kubernetes type: application -version: 0.1.0 -appVersion: "0.1.0" +version: 0.1.1 +appVersion: "0.1.1" kubeVersion: ">=1.29.0-0" home: https://github.com/vicotrbb/devboxes icon: https://raw.githubusercontent.com/vicotrbb/devboxes/main/docs/assets/devboxes-mark.svg diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 11c62f9..695db5a 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -213,7 +213,7 @@ dependencies = [ [[package]] name = "devbox-cli" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "chrono", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1e69850..cb18e3f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devbox-cli" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.96" description = "Terminal client for self-hosted Kubernetes development environments" diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 56ddedd..892ba8e 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devboxes-controller" -version = "0.1.0" +version = "0.1.1" description = "Controller and dashboard for self-hosted Kubernetes development environments" readme = "README.md" requires-python = ">=3.12" diff --git a/controller/src/devboxes_controller/__init__.py b/controller/src/devboxes_controller/__init__.py index 63cfc7a..5b6cc3c 100644 --- a/controller/src/devboxes_controller/__init__.py +++ b/controller/src/devboxes_controller/__init__.py @@ -1,3 +1,3 @@ """Devboxes controller package.""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/controller/src/devboxes_controller/resources.py b/controller/src/devboxes_controller/resources.py index 4be5e85..318013c 100644 --- a/controller/src/devboxes_controller/resources.py +++ b/controller/src/devboxes_controller/resources.py @@ -213,6 +213,10 @@ def build_deployment( }, "spec": { "replicas": 1, + # The prepared workspace image is intentionally comprehensive and + # can take longer than Kubernetes' ten-minute default to pull on a + # cold node or slower registry path. + "progressDeadlineSeconds": 1800, "strategy": {"type": "Recreate"}, "selector": {"matchLabels": {LABEL_NAME: request.name}}, "template": { diff --git a/controller/src/devboxes_controller/templates/docs.html b/controller/src/devboxes_controller/templates/docs.html index d97fdc3..8813fb6 100644 --- a/controller/src/devboxes_controller/templates/docs.html +++ b/controller/src/devboxes_controller/templates/docs.html @@ -5,9 +5,9 @@ Documentation · Devboxes - - - + + + diff --git a/controller/src/devboxes_controller/templates/index.html b/controller/src/devboxes_controller/templates/index.html index 129c21e..908ce42 100644 --- a/controller/src/devboxes_controller/templates/index.html +++ b/controller/src/devboxes_controller/templates/index.html @@ -5,9 +5,9 @@ Devboxes - - - + + + diff --git a/controller/src/devboxes_controller/templates/login.html b/controller/src/devboxes_controller/templates/login.html index 8f3d016..0a2c76e 100644 --- a/controller/src/devboxes_controller/templates/login.html +++ b/controller/src/devboxes_controller/templates/login.html @@ -5,8 +5,8 @@ Sign in · Devboxes - - + +
diff --git a/controller/tests/test_app.py b/controller/tests/test_app.py index f1c8e70..66c5fcc 100644 --- a/controller/tests/test_app.py +++ b/controller/tests/test_app.py @@ -32,7 +32,7 @@ def test_browser_login_and_dashboard_session() -> None: assert dashboard.headers["x-content-type-options"] == "nosniff" assert "Kubernetes connected" in dashboard.text assert "cluster default storage" in dashboard.text - styles = client.get("/static/styles.css?v=0.1.0") + styles = client.get("/static/styles.css?v=0.1.1") assert "[hidden]" in styles.text assert "display: none !important" in styles.text payload = client.get("/api/v1/devboxes").json() diff --git a/controller/tests/test_resources.py b/controller/tests/test_resources.py index 5eb61a4..ef8d93c 100644 --- a/controller/tests/test_resources.py +++ b/controller/tests/test_resources.py @@ -33,6 +33,7 @@ def test_deployment_is_hardened_and_ready_for_tmux_workspace() -> None: assert pod["automountServiceAccountToken"] is False assert pod["securityContext"]["fsGroup"] == 1000 assert pod["securityContext"]["fsGroupChangePolicy"] == "OnRootMismatch" + assert deployment["spec"]["progressDeadlineSeconds"] == 1800 assert deployment["spec"]["strategy"]["type"] == "Recreate" assert deployment["metadata"]["annotations"][ANNOTATION_TTL_HOURS] == "24" assert container["resources"]["limits"] == {"memory": "8Gi"} diff --git a/controller/uv.lock b/controller/uv.lock index 640a7e9..08d4655 100644 --- a/controller/uv.lock +++ b/controller/uv.lock @@ -382,7 +382,7 @@ wheels = [ [[package]] name = "devboxes-controller" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "fastapi" }, diff --git a/docs/configuration.md b/docs/configuration.md index a53952d..6294963 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,9 +3,9 @@ Use a values file for durable installations: ```bash -helm show values oci://ghcr.io/vicotrbb/charts/devboxes --version 0.1.0 > values.yaml +helm show values oci://ghcr.io/vicotrbb/charts/devboxes --version 0.1.1 > values.yaml helm upgrade --install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.1.0 \ + --version 0.1.1 \ --namespace devboxes \ --create-namespace \ --values values.yaml @@ -13,6 +13,8 @@ helm upgrade --install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ `values.schema.json` rejects unknown top-level and component fields, invalid service types, incomplete NodePort configuration, invalid ports, and out-of-range TTLs before Kubernetes resources are rendered. +When `scripts/install.sh` runs from a source checkout, it uses the local chart by default. Set `DEVBOXES_CHART_SOURCE=oci` and `DEVBOXES_VERSION=X.Y.Z` to force a released OCI chart, or set `DEVBOXES_CHART_SOURCE=local` to require the checkout chart. This prevents release verification from silently falling back to local templates. + ## Controller | Value | Default | Meaning | diff --git a/docs/development.md b/docs/development.md index 78bdc6d..4461423 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,8 +87,10 @@ helm template devboxes charts/devboxes --namespace devboxes kind create cluster --name devboxes ``` -The CI Kind job builds the controller image, loads it into a clean cluster, creates placeholder Secrets, installs the chart, waits for rollout, and exercises both the health and authenticated API endpoints. +The CI Kind job builds both images, loads them into a clean cluster, creates placeholder Secrets, installs the local chart, waits for rollout, and exercises the authenticated API, SSH, stop, retention, recreation, host identity, and purge lifecycle. ## Release contract -The CLI crate version, controller project version, chart `version`, and chart `appVersion` must match the release tag without the leading `v`. A `vX.Y.Z` tag creates a GitHub Release, publishes checksummed CLI archives, pushes multi-architecture images, attests image provenance, and publishes the chart to GHCR. +The CLI manifest and lockfile, controller package and lockfile, chart `version` and `appVersion`, repository package metadata, installer default, public examples, and static asset cache keys must match. `scripts/check-version.sh` enforces that contract and verifies the current changelog section and comparison link. + +A `vX.Y.Z` tag on an already-green `main` commit creates a GitHub Release, publishes four checksummed CLI archives, pushes multi-architecture images with SPDX SBOM and SLSA provenance attestations, and publishes the chart to GHCR. The workflow then verifies anonymous image pulls, both Linux architectures, signed provenance, public chart rendering under Helm 3 and Helm 4, strict Kubernetes schemas, the public checksum installer, and the complete clean-cluster lifecycle using only released chart and image artifacts. Run the same final lifecycle manually with `DEVBOXES_VERSION=X.Y.Z scripts/published-e2e.sh`. diff --git a/docs/golden-path.md b/docs/golden-path.md index 8eed615..b322f80 100644 --- a/docs/golden-path.md +++ b/docs/golden-path.md @@ -38,7 +38,7 @@ kubectl create namespace devboxes # Create devboxes-auth and devboxes-workspace here, as described below. helm upgrade --install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.1.0 \ + --version 0.1.1 \ --namespace devboxes \ --values values.yaml ``` diff --git a/docs/operations.md b/docs/operations.md index 2e677b0..1d88df0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -115,3 +115,9 @@ Back up required PVCs, delete boxes through the CLI, and use `--purge` only for Recreate the namespace, controller Secret, workspace Secret, and Helm release from versioned values. Restore home PVCs with their expected `devbox-NAME-home` names before recreating corresponding boxes. Reusing the name reconnects compute to the retained home and its SSH host identity. If the original controller token is unavailable, issue a new token and authenticate clients again. Kubernetes resources and PVC contents do not depend on browser or CLI sessions. + +## Repository protection recovery + +The public repository has no standing ruleset bypass. Normal recovery uses a short-lived branch, a pull request, and the complete required check set. Before changing protection, capture the active rulesets and their identifiers with `gh api repos/vicotrbb/devboxes/rulesets` so the original state is auditable. + +If GitHub Actions or the pull request service is unavailable and a critical security or release-integrity fix cannot wait, the repository owner may temporarily disable only the `devboxes-main` ruleset in **Settings → Rules → Rulesets**. Keep the release-tag ruleset active, make the smallest reviewed and signed recovery change, then immediately reactivate the unchanged main ruleset. Verify its `active` enforcement and full rule list through the API, run the omitted checks as soon as the service recovers, and record the incident, commit, reason, operator, timestamps, and validation in the relevant security advisory or issue. Never add a permanent administrator or organization-wide bypass for this procedure. diff --git a/package-lock.json b/package-lock.json index 3f17ee0..27bdc4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devboxes-repository-tooling", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devboxes-repository-tooling", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { "@eslint/js": "10.0.1", "eslint": "10.7.0", diff --git a/package.json b/package.json index dbfe227..cadd8b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devboxes-repository-tooling", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "description": "JavaScript and documentation quality gates for Devboxes", diff --git a/scripts/check-version.sh b/scripts/check-version.sh index 7f87eb9..3865827 100755 --- a/scripts/check-version.sh +++ b/scripts/check-version.sh @@ -6,14 +6,32 @@ project_directory="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" chart_version="$(awk '/^version:/ {print $2; exit}' "$project_directory/charts/devboxes/Chart.yaml")" app_version="$(awk '/^appVersion:/ {gsub(/"/, "", $2); print $2; exit}' "$project_directory/charts/devboxes/Chart.yaml")" cargo_version="$(awk -F '"' '/^version = / {print $2; exit}' "$project_directory/cli/Cargo.toml")" +cargo_lock_version="$(awk -F '"' '$0 == "name = \"devbox-cli\"" {found=1; next} found && /^version = / {print $2; exit}' "$project_directory/cli/Cargo.lock")" python_version="$(awk -F '"' '/^version = / {print $2; exit}' "$project_directory/controller/pyproject.toml")" package_version="$(awk -F '"' '/^__version__ = / {print $2; exit}' "$project_directory/controller/src/devboxes_controller/__init__.py")" +python_lock_version="$(awk -F '"' '$0 == "name = \"devboxes-controller\"" {found=1; next} found && /^version = / {print $2; exit}' "$project_directory/controller/uv.lock")" +npm_version="$(awk -F '"' '$2 == "version" {print $4; exit}' "$project_directory/package.json")" +npm_lock_versions="$(awk -F '"' '$2 == "version" {print $4; count++} count == 2 {exit}' "$project_directory/package-lock.json" | sort -u)" +# The single-quoted expression intentionally matches the literal shell default syntax. +# shellcheck disable=SC2016 +installer_version="$(sed -n 's/^version="${DEVBOXES_VERSION:-\([0-9][0-9.]*\)}"$/\1/p' "$project_directory/scripts/install.sh")" asset_versions="$(grep -hEo 'static/[^?]+\?v=[0-9]+\.[0-9]+\.[0-9]+' "$project_directory"/controller/src/devboxes_controller/templates/*.html | sed 's/.*?v=//' | sort -u)" +documented_versions="$(grep -hEo -- '--version [0-9]+\.[0-9]+\.[0-9]+' "$project_directory/README.md" "$project_directory"/docs/*.md | awk '{print $2}' | sort -u)" -for candidate in "$app_version" "$cargo_version" "$python_version" "$package_version"; do +for candidate in \ + "$app_version" \ + "$cargo_version" \ + "$cargo_lock_version" \ + "$python_version" \ + "$package_version" \ + "$python_lock_version" \ + "$npm_version" \ + "$npm_lock_versions" \ + "$installer_version" \ + "$documented_versions"; do if [ "$candidate" != "$chart_version" ]; then - printf 'error: release versions do not match: chart=%s app=%s cargo=%s python=%s package=%s\n' \ - "$chart_version" "$app_version" "$cargo_version" "$python_version" "$package_version" >&2 + printf 'error: release versions do not match chart version %s; found %s\n' \ + "$chart_version" "$candidate" >&2 exit 1 fi done @@ -21,5 +39,11 @@ if [ "$asset_versions" != "$chart_version" ]; then printf 'error: static asset version does not match release version: %s\n' "$asset_versions" >&2 exit 1 fi +if ! grep -Fq "## [$chart_version]" "$project_directory/CHANGELOG.md" \ + || ! grep -Fq "compare/v$chart_version...HEAD" "$project_directory/CHANGELOG.md"; then + printf 'error: changelog does not contain the %s release and Unreleased comparison link\n' \ + "$chart_version" >&2 + exit 1 +fi printf 'Release versions match: %s\n' "$chart_version" diff --git a/scripts/install.sh b/scripts/install.sh index 06ef96e..5423d77 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,8 +3,9 @@ set -eu release="${DEVBOXES_RELEASE:-devboxes}" namespace="${DEVBOXES_NAMESPACE:-devboxes}" -version="${DEVBOXES_VERSION:-0.1.0}" +version="${DEVBOXES_VERSION:-0.1.1}" repository="${DEVBOXES_CHART_REPOSITORY:-oci://ghcr.io/vicotrbb/charts/devboxes}" +chart_source="${DEVBOXES_CHART_SOURCE:-auto}" controller_secret="${DEVBOXES_CONTROLLER_SECRET:-devboxes-auth}" workspace_secret="${DEVBOXES_WORKSPACE_SECRET:-devboxes-workspace}" @@ -19,10 +20,27 @@ script_directory="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" project_directory="$(dirname "$script_directory")" chart="$repository" version_arguments="--version $version" -if [ -f "$project_directory/charts/devboxes/Chart.yaml" ]; then - chart="$project_directory/charts/devboxes" - version_arguments="" -fi +case "$chart_source" in + auto) + if [ -f "$project_directory/charts/devboxes/Chart.yaml" ]; then + chart="$project_directory/charts/devboxes" + version_arguments="" + fi + ;; + local) + chart="$project_directory/charts/devboxes" + version_arguments="" + if [ ! -f "$chart/Chart.yaml" ]; then + printf 'error: local chart not found at %s\n' "$chart" >&2 + exit 1 + fi + ;; + oci) ;; + *) + printf 'error: DEVBOXES_CHART_SOURCE must be auto, local, or oci\n' >&2 + exit 1 + ;; +esac kubectl create namespace "$namespace" --dry-run=client -o yaml | kubectl apply -f - >/dev/null if [ "${DEVBOXES_BOOTSTRAP_SECRETS:-0}" = 1 ] \ diff --git a/scripts/kind-e2e.sh b/scripts/kind-e2e.sh index 6ca0dab..ee4cb33 100755 --- a/scripts/kind-e2e.sh +++ b/scripts/kind-e2e.sh @@ -6,6 +6,9 @@ namespace="${DEVBOXES_NAMESPACE:-devboxes}" controller_port="${DEVBOXES_E2E_CONTROLLER_PORT:-18000}" ssh_port="${DEVBOXES_E2E_SSH_PORT:-12222}" node_image="${DEVBOXES_E2E_NODE_IMAGE:-kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f}" +published_version="${DEVBOXES_E2E_PUBLISHED_VERSION:-}" +released_cli="${DEVBOXES_E2E_CLI:-}" +workspace_timeout="${DEVBOXES_E2E_WORKSPACE_TIMEOUT:-3m}" token="e2e-access-token-at-least-32-characters" temporary_directory="$(mktemp -d)" controller_port_forward="" @@ -18,6 +21,17 @@ for command in kind kubectl helm docker curl jq ssh ssh-keygen nc; do exit 1 fi done +if [[ -n "$published_version" && ! "$published_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'error: DEVBOXES_E2E_PUBLISHED_VERSION must be a semantic version without a v prefix\n' >&2 + exit 1 +fi +if [[ -n "$published_version" && -z "${DEVBOXES_E2E_WORKSPACE_TIMEOUT:-}" ]]; then + workspace_timeout=10m +fi +if [[ -n "$released_cli" && ! -x "$released_cli" ]]; then + printf 'error: DEVBOXES_E2E_CLI is not executable: %s\n' "$released_cli" >&2 + exit 1 +fi previous_context="$(kubectl config current-context 2>/dev/null || true)" cleanup() { @@ -74,25 +88,77 @@ api() { curl -fsS -H "Authorization: Bearer $token" "$@" } +cli() { + DEVBOX_URL="http://127.0.0.1:$controller_port" \ + DEVBOX_TOKEN="$token" \ + DEVBOX_CONFIG="$temporary_directory/devbox-config.toml" \ + "$released_cli" "$@" +} + kind delete cluster --name "$cluster" >/dev/null 2>&1 || true kind create cluster --name "$cluster" --image "$node_image" --wait 120s -docker build --tag devboxes-controller:e2e controller -docker build --tag devboxes-workspace:e2e workspace -kind load docker-image --name "$cluster" devboxes-controller:e2e devboxes-workspace:e2e +if [[ -n "$published_version" ]]; then + case "$(uname -m)" in + x86_64 | amd64) platform_architecture=amd64 ;; + arm64 | aarch64) platform_architecture=arm64 ;; + *) + printf 'error: unsupported published E2E architecture: %s\n' "$(uname -m)" >&2 + exit 1 + ;; + esac + mkdir "$temporary_directory/docker-config" + for image in \ + "ghcr.io/vicotrbb/devboxes-controller:$published_version" \ + "ghcr.io/vicotrbb/devboxes-workspace:$published_version"; do + repository="${image%:*}" + manifest_digest="$( + docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' \ + | jq -r --arg architecture "$platform_architecture" ' + .manifests[] + | select(.platform.os == "linux" and .platform.architecture == $architecture) + | .digest + ' + )" + test -n "$manifest_digest" + DOCKER_CONFIG="$temporary_directory/docker-config" \ + docker pull "$repository@$manifest_digest" + docker tag "$repository@$manifest_digest" "$image" + done + kind load docker-image --name "$cluster" \ + "ghcr.io/vicotrbb/devboxes-controller:$published_version" \ + "ghcr.io/vicotrbb/devboxes-workspace:$published_version" +fi ssh-keygen -q -t ed25519 -N '' -f "$temporary_directory/id_ed25519" -DEVBOXES_ACCESS_TOKEN="$token" \ -DEVBOXES_SSH_PUBLIC_KEY="$temporary_directory/id_ed25519.pub" \ -DEVBOXES_NAMESPACE="$namespace" \ - scripts/install.sh \ - --set controller.image.repository=devboxes-controller \ - --set controller.image.tag=e2e \ - --set controller.image.pullPolicy=Never \ - --set workspace.image.repository=devboxes-workspace \ - --set workspace.image.tag=e2e \ - --set workspace.sshService.type=NodePort \ - --set workspace.sshService.host=dev-node.example.test +if [[ -n "$published_version" ]]; then + DEVBOXES_ACCESS_TOKEN="$token" \ + DEVBOXES_SSH_PUBLIC_KEY="$temporary_directory/id_ed25519.pub" \ + DEVBOXES_NAMESPACE="$namespace" \ + DEVBOXES_CHART_SOURCE=oci \ + DEVBOXES_VERSION="$published_version" \ + scripts/install.sh \ + --set workspace.sshService.type=NodePort \ + --set workspace.sshService.host=dev-node.example.test + controller_image="$(kubectl -n "$namespace" get deployment devboxes -o jsonpath='{.spec.template.spec.containers[0].image}')" + test "$controller_image" = "ghcr.io/vicotrbb/devboxes-controller:$published_version" +else + docker build --tag devboxes-controller:e2e controller + docker build --tag devboxes-workspace:e2e workspace + kind load docker-image --name "$cluster" devboxes-controller:e2e devboxes-workspace:e2e + + DEVBOXES_ACCESS_TOKEN="$token" \ + DEVBOXES_SSH_PUBLIC_KEY="$temporary_directory/id_ed25519.pub" \ + DEVBOXES_NAMESPACE="$namespace" \ + scripts/install.sh \ + --set controller.image.repository=devboxes-controller \ + --set controller.image.tag=e2e \ + --set controller.image.pullPolicy=Never \ + --set workspace.image.repository=devboxes-workspace \ + --set workspace.image.tag=e2e \ + --set workspace.sshService.type=NodePort \ + --set workspace.sshService.host=dev-node.example.test +fi GH_TOKEN=preserved-runtime-test-value \ DEVBOXES_SSH_PUBLIC_KEY="$temporary_directory/id_ed25519.pub" \ @@ -116,12 +182,23 @@ wait_for_http \ "$controller_port_forward" \ "$temporary_directory/controller-port-forward.log" api "http://127.0.0.1:$controller_port/api/v1/whoami" >/dev/null +if [[ -n "$released_cli" ]]; then + cli --json list | jq -e 'type == "array"' >/dev/null +fi -api \ - -H 'Content-Type: application/json' \ - -d '{"name":"smoke","preset":"small","ttl_hours":4,"repository":null}' \ - "http://127.0.0.1:$controller_port/api/v1/devboxes" >/dev/null -kubectl -n "$namespace" rollout status deployment/devbox-smoke --timeout=3m +if [[ -n "$released_cli" ]]; then + cli --json create smoke --preset small --ttl 4 --no-wait >/dev/null +else + api \ + -H 'Content-Type: application/json' \ + -d '{"name":"smoke","preset":"small","ttl_hours":4,"repository":null}' \ + "http://127.0.0.1:$controller_port/api/v1/devboxes" >/dev/null +fi +kubectl -n "$namespace" rollout status deployment/devbox-smoke --timeout="$workspace_timeout" +if [[ -n "$published_version" ]]; then + workspace_image="$(kubectl -n "$namespace" get deployment devbox-smoke -o jsonpath='{.spec.template.spec.containers[0].image}')" + test "$workspace_image" = "ghcr.io/vicotrbb/devboxes-workspace:$published_version" +fi for _ in {1..30}; do state="$(api "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke" | jq -r .state)" @@ -151,7 +228,11 @@ ssh \ kill "$ssh_port_forward" >/dev/null 2>&1 || true ssh_port_forward="" -api -X POST "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke/stop" >/dev/null +if [[ -n "$released_cli" ]]; then + cli --json stop smoke >/dev/null +else + api -X POST "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke/stop" >/dev/null +fi for _ in {1..30}; do replicas="$(kubectl -n "$namespace" get deployment devbox-smoke -o jsonpath='{.spec.replicas}')" [[ "$replicas" == 0 ]] && break @@ -159,20 +240,37 @@ for _ in {1..30}; do done test "$replicas" = 0 -api -X DELETE "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke?purge=false" >/dev/null +if [[ -n "$released_cli" ]]; then + cli delete smoke >/dev/null +else + api -X DELETE "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke?purge=false" >/dev/null +fi kubectl -n "$namespace" wait --for=delete deployment/devbox-smoke --timeout=2m kubectl -n "$namespace" wait --for=delete service/devbox-smoke-ssh --timeout=2m kubectl -n "$namespace" get pvc devbox-smoke-home >/dev/null -api \ - -H 'Content-Type: application/json' \ - -d '{"name":"smoke","preset":"small","ttl_hours":4,"repository":null}' \ - "http://127.0.0.1:$controller_port/api/v1/devboxes" >/dev/null -kubectl -n "$namespace" rollout status deployment/devbox-smoke --timeout=3m +if [[ -n "$released_cli" ]]; then + cli --json create smoke --preset small --ttl 4 --no-wait >/dev/null +else + api \ + -H 'Content-Type: application/json' \ + -d '{"name":"smoke","preset":"small","ttl_hours":4,"repository":null}' \ + "http://127.0.0.1:$controller_port/api/v1/devboxes" >/dev/null +fi +kubectl -n "$namespace" rollout status deployment/devbox-smoke --timeout="$workspace_timeout" second_host_key="$(kubectl -n "$namespace" exec deployment/devbox-smoke -- cat /home/dev/.devbox/ssh/ssh_host_ed25519_key.pub)" test "$first_host_key" = "$second_host_key" -api -X DELETE "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke?purge=true" >/dev/null +if [[ -n "$released_cli" ]]; then + cli delete smoke --purge --yes >/dev/null +else + api -X DELETE "http://127.0.0.1:$controller_port/api/v1/devboxes/smoke?purge=true" >/dev/null +fi kubectl -n "$namespace" wait --for=delete pvc/devbox-smoke-home --timeout=2m -printf 'Verified clean install, API, PVC, NodePort %s, SSH, stop, retain, reuse, host identity, and purge.\n' "$node_port" +if [[ -n "$published_version" ]]; then + printf 'Verified published %s chart, images, and CLI through clean install, API, PVC, NodePort %s, SSH, stop, retain, reuse, host identity, and purge.\n' \ + "$published_version" "$node_port" +else + printf 'Verified clean install, API, PVC, NodePort %s, SSH, stop, retain, reuse, host identity, and purge.\n' "$node_port" +fi diff --git a/scripts/published-e2e.sh b/scripts/published-e2e.sh new file mode 100755 index 0000000..60d5b0a --- /dev/null +++ b/scripts/published-e2e.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +version="${DEVBOXES_VERSION:-${1:-}}" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'usage: DEVBOXES_VERSION=X.Y.Z %s\n' "$0" >&2 + exit 1 +fi + +project_directory="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT INT TERM + +INSTALL_DIR="$temporary_directory/bin" \ +DEVBOX_VERSION="$version" \ + "$project_directory/scripts/install-devbox-cli.sh" + +cli_version="$("$temporary_directory/bin/devbox" --version)" +if [[ "$cli_version" != "devbox $version" ]]; then + printf 'error: released CLI version mismatch: expected %s, found %s\n' \ + "devbox $version" "$cli_version" >&2 + exit 1 +fi + +DEVBOXES_E2E_PUBLISHED_VERSION="$version" \ +DEVBOXES_E2E_CLI="$temporary_directory/bin/devbox" \ + "$project_directory/scripts/kind-e2e.sh" diff --git a/scripts/verify-published-images.sh b/scripts/verify-published-images.sh new file mode 100755 index 0000000..a24d65d --- /dev/null +++ b/scripts/verify-published-images.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +version="${DEVBOXES_VERSION:-${1:-}}" +repository="${GITHUB_REPOSITORY:-vicotrbb/devboxes}" +owner="${repository%%/*}" +temporary_directory="$(mktemp -d)" +trap 'rm -rf "$temporary_directory"' EXIT INT TERM +mkdir "$temporary_directory/docker" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'usage: DEVBOXES_VERSION=X.Y.Z %s\n' "$0" >&2 + exit 1 +fi + +for command in docker jq gh; do + if ! command -v "$command" >/dev/null 2>&1; then + printf 'error: %s is required\n' "$command" >&2 + exit 1 + fi +done + +for component in controller workspace; do + image="ghcr.io/$owner/devboxes-$component:$version" + manifest="$(docker buildx imagetools inspect "$image" --raw)" + index_digest="$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | jq -r .digest)" + + jq -e ' + [.manifests[] | select(.platform.os == "linux") | .platform.architecture] + | sort == ["amd64", "arm64"] + ' <<<"$manifest" >/dev/null + + attestations="$( + jq -r '.manifests[] + | select(.annotations["vnd.docker.reference.type"] == "attestation-manifest") + | .digest' <<<"$manifest" + )" + attestation_count="$(awk 'NF {count++} END {print count + 0}' <<<"$attestations")" + if [[ "$attestation_count" -ne 2 ]]; then + printf 'error: expected two platform attestation manifests for %s\n' "$image" >&2 + exit 1 + fi + while IFS= read -r attestation; do + docker buildx imagetools inspect "ghcr.io/$owner/devboxes-$component@$attestation" --raw \ + | jq -e ' + [.layers[].annotations["in-toto.io/predicate-type"]] + | contains(["https://spdx.dev/Document", "https://slsa.dev/provenance/v1"]) + ' >/dev/null + done <<<"$attestations" + + DOCKER_CONFIG="$temporary_directory/docker" docker pull "$image" >/dev/null + DOCKER_CONFIG="$temporary_directory/docker" docker image rm "$image" >/dev/null + gh attestation verify "oci://ghcr.io/$owner/devboxes-$component@$index_digest" \ + --repo "$repository" \ + --cert-identity "https://github.com/$repository/.github/workflows/release.yml@refs/tags/v$version" \ + --source-ref "refs/tags/v$version" \ + --format json \ + | jq -e 'length > 0' >/dev/null + + printf 'Verified anonymous pull, amd64/arm64 manifests, SPDX SBOMs, and signed provenance for %s@%s.\n' \ + "$image" "$index_digest" +done