diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index e12b081..0000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,5 +0,0 @@ -self-hosted-runner: - labels: - - tako-vm-*-amd64 - - tako-vm-*-arm64 -config-variables: null diff --git a/.github/actions/setup-linux-backend/action.yml b/.github/actions/setup-linux-backend/action.yml index fd19057..27ded00 100644 --- a/.github/actions/setup-linux-backend/action.yml +++ b/.github/actions/setup-linux-backend/action.yml @@ -7,9 +7,17 @@ runs: shell: bash run: | set -euo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - gcc \ - libpam0g-dev \ - libsystemd-dev \ - systemd + # Only hit apt when something is actually missing. Each CI job runs + # this once on a fresh runner, so per-job cost is one update at most. + # Callers needing several distro tags must loop tags in a single job + # instead of adding a matrix that repeats this setup. + missing=() + for pkg in gcc libpam0g-dev libsystemd-dev systemd; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + missing+=("$pkg") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + sudo apt-get update + sudo apt-get install --yes --no-install-recommends "${missing[@]}" + fi diff --git a/.github/coverage-baseline.env b/.github/coverage-baseline.env deleted file mode 100644 index 940119b..0000000 --- a/.github/coverage-baseline.env +++ /dev/null @@ -1,2 +0,0 @@ -# Informational until the first stable CI run records the hosted-runner value. -GO_STATEMENT_COVERAGE_BASELINE=42.6 diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml deleted file mode 100644 index 75a7273..0000000 --- a/.github/workflows/beta-release.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: Beta release - -on: - push: - tags: - - "v*-beta*" - workflow_dispatch: - -permissions: - contents: read - -jobs: - ci: - name: Required CI gates - uses: ./.github/workflows/ci.yml - permissions: - contents: read - - integration: - name: Required VM integration - needs: ci - uses: ./.github/workflows/integration.yml - permissions: - contents: read - - native-packages: - name: Native package matrix - needs: ci - uses: ./.github/workflows/native-packages.yml - permissions: - contents: read - - lifecycle-validation: - name: Representative lifecycle validation - needs: ci - uses: ./.github/workflows/packaging-lifecycle.yml - permissions: - contents: read - - vm-validation: - name: Disposable VM validation matrix - needs: native-packages - uses: ./.github/workflows/vm-validation.yml - permissions: - contents: read - actions: read - - evidence-gate: - name: Verify all VM evidence - needs: [ci, integration, native-packages, vm-validation, lifecycle-validation] - runs-on: ubuntu-24.04 - permissions: - contents: read - actions: read - id-token: write - attestations: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: apps/backend/go.mod - cache: true - cache-dependency-path: | - apps/backend/go.sum - go.work.sum - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - uses: actions/download-artifact@v4 - with: - pattern: package-* - path: incoming - merge-multiple: true - - uses: actions/download-artifact@v4 - with: - pattern: vm-evidence-* - path: incoming - merge-multiple: true - - name: Fail closed unless every manifest target passed in a VM - run: TAKO_VM_COMMIT="$GITHUB_SHA" ./tools/release-gate incoming incoming - - name: Assemble packages from the manifest - run: bun tools/release-assemble incoming release - - name: Record dependency inventory - run: bun tools/release-dependencies release/dependency-inventory.json - - name: Generate release checksums - run: | - set -euo pipefail - checksums_tmp=$(mktemp) - trap 'rm -f "$checksums_tmp"' EXIT - cd release - find . -maxdepth 1 -type f ! -name checksums.txt -print0 \ - | sort -z \ - | xargs -0 sha256sum > "$checksums_tmp" - mv "$checksums_tmp" checksums.txt - - uses: sigstore/cosign-installer@v3 - - name: Sign the release checksum manifest - run: | - cosign sign-blob --yes \ - --output-signature release/checksums.txt.sig \ - --output-certificate release/checksums.txt.pem \ - release/checksums.txt - - name: Attest release provenance - uses: actions/attest-build-provenance@v2 - with: - subject-path: release/* - - uses: actions/upload-artifact@v4 - with: - name: beta-release-inputs - path: release/ - if-no-files-found: error - retention-days: 30 - - publish: - name: Publish beta release - needs: evidence-gate - if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-beta') - runs-on: ubuntu-24.04 - permissions: - contents: write - steps: - - uses: actions/download-artifact@v4 - with: - name: beta-release-inputs - path: release - - name: Publish only after CI, native builds, and every VM evidence gate passed - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - prerelease: true - generate_release_notes: true - files: release/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ec0d06..3bcd2e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: workflow_call: @@ -14,15 +15,94 @@ concurrency: env: BUN_VERSION: 1.4.0 + ACTIONLINT_VERSION: 1.7.7 + REDOCLY_CLI_VERSION: 1.34.0 + STATICCHECK_VERSION: 2026.1 +# All jobs run in parallel with no `needs:` edges for fast failure. +# No per-job path filtering: shared files (package.json, bun.lock, nx.json, +# Go workspace/module files, .github/**, tools/**, packaging/**) can affect +# several apps, so every job always runs. Predictable over clever. +# Runner count per normal PR: 7 (was 3). distro-tags loops 7 tags in one +# runner instead of a 7-way matrix; true distro package builds stay in +# native-packages.yml / release.yml, not PR CI. jobs: - go-unit: - name: Go unit tests and coverage + repo-validation: + name: Repo validation runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - name: Lint OpenAPI + run: bunx --bun @redocly/cli@${{ env.REDOCLY_CLI_VERSION }} lint --config .redocly.yaml apps/backend/api/openapi.yaml + - name: Install actionlint + env: + ACTIONLINT_VERSION: ${{ env.ACTIONLINT_VERSION }} + run: | + set -euo pipefail + arch=$(uname -m) + case "$arch" in + x86_64) arch=amd64 ;; + aarch64) arch=arm64 ;; + *) echo "unsupported arch: $arch" >&2; exit 1 ;; + esac + bindir="${RUNNER_TEMP}/bin" + mkdir -p "$bindir" + curl -fsSL -o "$RUNNER_TEMP/actionlint.tar.gz" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_${arch}.tar.gz" + tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$bindir" actionlint + echo "$bindir" >> "$GITHUB_PATH" + - run: actionlint + - name: Run shellcheck + run: | + set -euo pipefail + # ubuntu-24.04 images already ship shellcheck; never run + # apt-get update just for this. Fall back to install only when + # the preinstalled tool is genuinely absent. + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install --yes --no-install-recommends shellcheck + fi + shellcheck --severity=error \ + tools/package \ + tools/package-check \ + tools/tako-go.sh \ + tools/tako-host-test \ + tools/tako-integration \ + apps/backend/packaging/*.sh \ + apps/backend/packaging/scripts/deb/*.sh \ + apps/backend/packaging/scripts/rpm/*.sh \ + apps/backend/packaging/scripts/arch/*.sh + - name: Check shell syntax + run: | + set -euo pipefail + for script in \ + tools/package \ + tools/package-check \ + tools/tako-go.sh \ + apps/backend/packaging/scripts/test.sh \ + apps/backend/packaging/scripts/deb/*.sh \ + apps/backend/packaging/scripts/rpm/*.sh \ + apps/backend/packaging/scripts/arch/*.sh; do + sh -n "$script" + done + for script in \ + tools/tako-host-test \ + tools/tako-integration \ + apps/backend/packaging/smoke-test.sh \ + apps/backend/packaging/lifecycle-test.sh; do + bash -n "$script" + done + + backend-ubuntu: + name: Backend (ubuntu, authoritative) + runs-on: ubuntu-24.04 + timeout-minutes: 30 env: TAKO_DISTRO: ubuntu - GOCACHE: /tmp/tako-go-ci-cache - GO_COVERAGE_ENFORCE: ${{ vars.GO_COVERAGE_ENFORCE || 'false' }} steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-linux-backend @@ -33,41 +113,17 @@ jobs: cache-dependency-path: | apps/backend/go.sum go.work.sum - - name: Run Go suite with coverage + - name: Run Go race tests with coverage working-directory: apps/backend run: | set -euo pipefail mkdir -p "$GITHUB_WORKSPACE/coverage/go" - ../../tools/tako-go.sh test \ - -covermode=atomic \ - -coverprofile="$GITHUB_WORKSPACE/coverage/go/coverage.out" \ - ./... + ../../tools/tako-go.sh test -race -covermode=atomic \ + -coverprofile="$GITHUB_WORKSPACE/coverage/go/coverage.out" ./... go tool cover -func="$GITHUB_WORKSPACE/coverage/go/coverage.out" \ | tee "$GITHUB_WORKSPACE/coverage/go/coverage.txt" go tool cover -html="$GITHUB_WORKSPACE/coverage/go/coverage.out" \ -o "$GITHUB_WORKSPACE/coverage/go/coverage.html" - - name: Report Go coverage baseline - working-directory: apps/backend - run: | - set -euo pipefail - baseline=$(sed -n 's/^GO_STATEMENT_COVERAGE_BASELINE=//p' "$GITHUB_WORKSPACE/.github/coverage-baseline.env") - current=$(awk '/^total:/ { gsub("%", "", $NF); print $NF }' "$GITHUB_WORKSPACE/coverage/go/coverage.txt") - test -n "$baseline" - test -n "$current" - enforcement="informational until the first stable CI baseline run" - if [ "${GO_COVERAGE_ENFORCE:-false}" = true ]; then - enforcement="blocking" - fi - { - echo "### Go coverage" - echo "- Current statement coverage: ${current}%" - echo "- Recorded baseline: ${baseline}%" - echo "- Enforcement: ${enforcement}" - } >> "$GITHUB_STEP_SUMMARY" - if [ "${GO_COVERAGE_ENFORCE:-false}" = true ] && awk "BEGIN { exit !($current < $baseline) }"; then - echo "Go coverage regressed from ${baseline}% to ${current}%" >&2 - exit 1 - fi - name: Upload Go coverage if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -76,30 +132,24 @@ jobs: path: coverage/go/ if-no-files-found: warn retention-days: 14 - - go-race: - name: Go race tests - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-linux-backend - - uses: actions/setup-go@v5 - with: - go-version-file: apps/backend/go.mod - cache: true - cache-dependency-path: | - apps/backend/go.sum - go.work.sum - - name: Run Go race suite + - name: Run go vet working-directory: apps/backend + run: ../../tools/tako-go.sh vet ./... + - name: Install staticcheck env: - TAKO_DISTRO: ubuntu - GOCACHE: /tmp/tako-go-race-cache - run: ../../tools/tako-go.sh test -race ./... + GOBIN: ${{ runner.temp }}/bin + run: | + mkdir -p "$GOBIN" + go install honnef.co/go/tools/cmd/staticcheck@${{ env.STATICCHECK_VERSION }} + echo "$GOBIN" >> "$GITHUB_PATH" + - name: Run staticcheck + working-directory: apps/backend + run: staticcheck -checks=all,-U1000,-SA1019,-S1017,-S1016,-ST1000,-ST1020 -tags ubuntu ./... - go-quality: - name: Go vet and static analysis + distro-tags: + name: Distro tags runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-linux-backend @@ -110,54 +160,54 @@ jobs: cache-dependency-path: | apps/backend/go.sum go.work.sum - - name: Install staticcheck - env: - GOBIN: ${{ runner.temp }}/bin - run: | - mkdir -p "$GOBIN" - go install honnef.co/go/tools/cmd/staticcheck@2026.1 - echo "$GOBIN" >> "$GITHUB_PATH" - - name: Run go vet + - name: Validate every distro build tag on one host working-directory: apps/backend - env: - TAKO_DISTRO: ubuntu - GOCACHE: /tmp/tako-go-quality-cache - run: ../../tools/tako-go.sh vet ./... - - name: Run staticcheck + run: | + set -euo pipefail + # Same Ubuntu host for all tags: validates tag-gated Go code + # compiles and unit tests pass per distro. Not true distro + # environments; real package/install testing stays in + # native-packages.yml. Ubuntu is excluded here: backend-ubuntu + # (race, coverage, vet, staticcheck) and build already cover it. + # No race, coverage, or staticcheck here; backend-ubuntu remains + # the authoritative quality job. `go list` is omitted: vet, test, + # and build each resolve the full package graph for the selected + # tag, so list would prove nothing extra. + # No tag needs runner isolation: each tako-go.sh invocation is a + # fresh `go` process with its own -tags value, no daemon or + # filesystem state leaks between iterations. + for tag in debian fedora rhel rocky almalinux opensuse archlinux; do + echo "--- TAKO_DISTRO=$tag ---" + TAKO_DISTRO="$tag" ../../tools/tako-go.sh vet ./... + TAKO_DISTRO="$tag" ../../tools/tako-go.sh test ./... + TAKO_DISTRO="$tag" ../../tools/tako-go.sh build ./... + done + - name: Verify untagged build fails on the distro guard working-directory: apps/backend - env: - GOCACHE: /tmp/tako-go-quality-cache - STATICCHECK_CACHE: ${{ runner.temp }}/staticcheck-cache - # The first staticcheck baseline keeps pre-existing dead-code and - # package-comment findings informational; all other checks are gated. - run: staticcheck -checks=all,-U1000,-SA1019,-S1017,-S1016,-ST1000,-ST1020 -tags ubuntu ./... - - dashboard-tests: - name: Dashboard unit tests - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - run: bun install --frozen-lockfile - - run: bun nx test dashboard - - dashboard-quality: - name: Dashboard typecheck and lint - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - run: bun install --frozen-lockfile - - run: bun nx typecheck dashboard - - run: bun nx lint dashboard + run: | + set -euo pipefail + # updates_tag_required.go references undefined + # distro_build_tag_required so a build without a supported tag + # fails. Assert the guard reason, not just any failure. + set +e + output=$(go build ./... 2>&1) + build_status=$? + set -e + if [ "$build_status" -eq 0 ]; then + echo "expected untagged backend build to fail" >&2 + exit 1 + fi + if ! printf '%s\n' "$output" | grep -Fq 'distro_build_tag_required'; then + echo "untagged build failed, but not because of the distro-tag guard:" >&2 + printf '%s\n' "$output" >&2 + exit 1 + fi + echo "untagged build correctly rejected: distro_build_tag_required" - dashboard-coverage: - name: Dashboard coverage + dashboard: + name: Dashboard runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -173,10 +223,17 @@ jobs: path: apps/dashboard/coverage/ if-no-files-found: warn retention-days: 14 + - run: bun nx typecheck dashboard + - run: bun nx lint dashboard + # No production build here. The dashboard production build runs exactly + # once in the build job via `nx build backend` (dependsOn ^build). + # Separate runners share no local Nx cache, so building here too would + # mean building twice with no benefit. web: - name: Web typecheck, lint, and build + name: Web runs-on: ubuntu-24.04 + timeout-minutes: 20 env: ASTRO_TELEMETRY_DISABLED: "1" steps: @@ -185,12 +242,17 @@ jobs: with: bun-version: ${{ env.BUN_VERSION }} - run: bun install --frozen-lockfile - - run: bun run web:check - - run: bun run web:build + - run: bun nx lint web + # Typecheck runs through the build target's dependsOn graph, not as a + # separate step. + - run: bun nx build web - backend-build: - name: Backend binary build + build: + name: Build runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + TAKO_DISTRO: ubuntu steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-linux-backend @@ -205,15 +267,22 @@ jobs: with: bun-version: ${{ env.BUN_VERSION }} - run: bun install --frozen-lockfile - - name: Build backend for Ubuntu - env: - TAKO_DISTRO: ubuntu - run: bun nx build backend + # Builds the dashboard production bundle exactly once (via ^build) + # then the backend binary with embedded dashboard. Ubuntu tag only; + # other tags are compile-checked in distro-tags. + - run: bun run build - packaging: - name: Packaging manifest and hook checks + packaging-validation: + name: Packaging validation runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + TAKO_DISTRO: ubuntu steps: + # Both setups are load-bearing: package:check probes PAM headers via + # cc -lpam and runs systemd-analyze verify (setup-linux-backend), and + # reads `go env` (CGO_ENABLED, GOARCH) plus `goreleaser check` (setup-go + # plus GoReleaser below). It compiles no Go code itself. - uses: actions/checkout@v4 - uses: ./.github/actions/setup-linux-backend - uses: actions/setup-go@v5 @@ -227,88 +296,14 @@ jobs: with: bun-version: ${{ env.BUN_VERSION }} - run: bun install --frozen-lockfile - - name: Install GoReleaser for static configuration checks - uses: goreleaser/goreleaser-action@v7 + - uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser version: "v${{ vars.GORELEASER_VERSION || '2.18.0' }}" install-only: true + # package:check runs goreleaser check, manifest cross-checks, + # systemd-analyze verify, plus mocked maintainer-script and host + # tests. Safe for PR CI. Full snapshot builds, native Docker + # package builds, and tools/tako-integration stay in release-only + # workflows. - run: bun run package:check - - run: bun test tools/release-gate.test.ts - - openapi-contract: - name: OpenAPI contract validation - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: ${{ env.BUN_VERSION }} - - run: bunx --bun @redocly/cli@1.34.0 lint --config .redocly.yaml apps/backend/api/openapi.yaml - - workflow-quality: - name: GitHub workflow validation - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: apps/backend/go.mod - cache: true - cache-dependency-path: | - apps/backend/go.sum - go.work.sum - - name: Install actionlint - env: - GOBIN: ${{ runner.temp }}/bin - run: | - mkdir -p "$GOBIN" - go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 - echo "$GOBIN" >> "$GITHUB_PATH" - - run: actionlint - - shell-quality: - name: Shell validation - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - - name: Install shellcheck - run: | - sudo apt-get update - sudo apt-get install --yes --no-install-recommends shellcheck - - name: Run shellcheck - run: >- - shellcheck --severity=error - tools/package - tools/package-check - tools/tako-go.sh - tools/tako-host-test - tools/tako-integration - apps/backend/packaging/*.sh - apps/backend/packaging/scripts/deb/*.sh - apps/backend/packaging/scripts/rpm/*.sh - apps/backend/packaging/scripts/arch/*.sh - - pr-gate: - name: PR gate - if: ${{ always() }} - needs: - - go-unit - - go-race - - go-quality - - dashboard-tests - - dashboard-quality - - dashboard-coverage - - web - - backend-build - - packaging - - openapi-contract - - workflow-quality - - shell-quality - runs-on: ubuntu-24.04 - steps: - - name: Fail if a required check failed, was cancelled, or skipped - if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} - run: exit 1 - - name: All required checks passed - run: echo "All required CI checks passed" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml deleted file mode 100644 index 688d5ec..0000000 --- a/.github/workflows/integration.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Backend integration - -on: - workflow_call: - workflow_dispatch: - schedule: - - cron: "17 2 * * 1-5" - -permissions: - contents: read - -jobs: - integration: - name: ${{ matrix.distro }} disposable VM integration - strategy: - fail-fast: false - matrix: - include: - - distro: ubuntu - vm_label: tako-vm-ubuntu2604-amd64 - - distro: fedora - vm_label: tako-vm-fedora44-amd64 - runs-on: [self-hosted, linux, "${{ matrix.vm_label }}"] - timeout-minutes: 30 - env: - TAKO_INTEGRATION_DISTRO: ${{ matrix.distro }} - TAKO_INTEGRATION_EVIDENCE_DIR: integration-evidence/${{ matrix.distro }} - GOCACHE: /tmp/tako-go-integration-cache - GOMODCACHE: /tmp/tako-go-integration-mod-cache - TAKO_TEST_PAM_USER: tako-ci - TAKO_TEST_BINARY: ${{ github.workspace }}/bin/tako-integration - TAKO_TEST_ACCOUNT_VM: "1" - TAKO_TEST_IDENTITY_VM: "1" - TAKO_TEST_JOURNAL_VM: "1" - TAKO_TEST_LOGIN_HISTORY_USER: tako-ci - TAKO_TEST_PROCESS_VM: "1" - TAKO_TEST_OVERRIDE_VM: "1" - TAKO_TEST_SSH_HOME: /home/tako-ci - TAKO_TEST_SSH_USER: tako-ci - TAKO_TEST_TIMER_VM: "1" - TAKO_TEST_PASSWORD_USER: tako-ci - TAKO_TEST_PASSWORD_SERVICE: passwd - TAKO_TEST_PAM_SERVICE: tako - steps: - - uses: actions/checkout@v4 - - name: Install backend prerequisites - run: | - set -euo pipefail - case "$TAKO_INTEGRATION_DISTRO" in - ubuntu) - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - gcc libpam0g-dev libsystemd-dev systemd openssl python3 - ;; - fedora) - sudo dnf install --assumeyes \ - gcc pam-devel systemd systemd-devel openssl python3 - ;; - esac - - uses: actions/setup-go@v5 - with: - go-version-file: apps/backend/go.mod - cache: true - cache-dependency-path: | - apps/backend/go.sum - go.work.sum - - name: Generate disposable PAM password fixture - run: | - set -euo pipefail - old_password="tako-ci-$(openssl rand -hex 16)" - new_password="tako-ci-$(openssl rand -hex 16)" - echo "::add-mask::$old_password" - echo "::add-mask::$new_password" - { - printf 'TAKO_TEST_PASSWORD_OLD=%s\n' "$old_password" - printf 'TAKO_TEST_PASSWORD_NEW=%s\n' "$new_password" - printf 'TAKO_TEST_PAM_RESPONSES=["%s"]\n' "$old_password" - } >> "$GITHUB_ENV" - - name: Build integration binary - working-directory: apps/backend - env: - TAKO_DISTRO: ${{ matrix.distro }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE/bin" - go build -tags "$TAKO_DISTRO" -buildvcs=false \ - -o "$GITHUB_WORKSPACE/bin/tako-integration" ./cmd/tako - - name: Run tagged integration suite and VM preflight - run: | - set -euo pipefail - sudo --preserve-env=PATH,GOCACHE,GOMODCACHE,TAKO_INTEGRATION_DISTRO,TAKO_INTEGRATION_EVIDENCE_DIR,TAKO_TEST_BINARY,TAKO_TEST_PAM_USER,TAKO_TEST_PAM_RESPONSES,TAKO_TEST_PASSWORD_OLD,TAKO_TEST_PASSWORD_NEW,TAKO_TEST_ACCOUNT_VM,TAKO_TEST_IDENTITY_VM,TAKO_TEST_JOURNAL_VM,TAKO_TEST_LOGIN_HISTORY_USER,TAKO_TEST_PROCESS_VM,TAKO_TEST_OVERRIDE_VM,TAKO_TEST_SSH_HOME,TAKO_TEST_SSH_USER,TAKO_TEST_TIMER_VM,TAKO_TEST_PASSWORD_USER,TAKO_TEST_PASSWORD_SERVICE,TAKO_TEST_PAM_SERVICE \ - "$GITHUB_WORKSPACE/tools/tako-integration" - - name: Upload integration logs and diagnostics - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: integration-evidence-${{ matrix.distro }} - path: integration-evidence/${{ matrix.distro }}/ - if-no-files-found: warn - retention-days: 30 diff --git a/.github/workflows/packaging-lifecycle.yml b/.github/workflows/packaging-lifecycle.yml deleted file mode 100644 index eb0e13b..0000000 --- a/.github/workflows/packaging-lifecycle.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Packaging lifecycle - -on: - workflow_call: - workflow_dispatch: - schedule: - - cron: "37 3 * * 1-5" - -permissions: - contents: read - -jobs: - build: - name: ${{ matrix.target }} old and new packages - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - include: - - target: ubuntu2604-amd64 - arch: amd64 - image: ubuntu:26.04 - - target: fedora44-amd64 - arch: amd64 - image: fedora:44 - env: - TARGET_ID: ${{ matrix.target }} - TARGET_ARCH: ${{ matrix.arch }} - TARGET_IMAGE: ${{ matrix.image }} - steps: - - uses: actions/checkout@v4 - - name: Build old lifecycle package - run: | - set -euo pipefail - docker run --rm --platform "linux/${TARGET_ARCH}" \ - --volume "$GITHUB_WORKSPACE:/workspace" \ - --workdir /workspace \ - --env TAKO_PACKAGE_TARGET="$TARGET_ID" \ - --env GORELEASER_CURRENT_TAG="v0.0.1-ci.1.${GITHUB_RUN_ID}" \ - "$TARGET_IMAGE" \ - /bin/bash -c './apps/backend/packaging/native-build.sh' - package=$(find "$GITHUB_WORKSPACE/dist" -maxdepth 1 -type f \( \ - -name '*.deb' -o -name '*.rpm' -o -name '*.pkg.tar.zst' \ - \) -print -quit) - test -n "$package" - artifact_dir="$RUNNER_TEMP/lifecycle-artifacts/$TARGET_ID" - mkdir -p "$artifact_dir" - cp "$package" "$artifact_dir/old-$(basename "$package")" - - name: Build new lifecycle package - run: | - set -euo pipefail - docker run --rm --platform "linux/${TARGET_ARCH}" \ - --volume "$GITHUB_WORKSPACE:/workspace" \ - --workdir /workspace \ - --env TAKO_PACKAGE_TARGET="$TARGET_ID" \ - --env GORELEASER_CURRENT_TAG="v0.0.1-ci.2.${GITHUB_RUN_ID}" \ - "$TARGET_IMAGE" \ - /bin/bash -c './apps/backend/packaging/native-build.sh' - package=$(find "$GITHUB_WORKSPACE/dist" -maxdepth 1 -type f \( \ - -name '*.deb' -o -name '*.rpm' -o -name '*.pkg.tar.zst' \ - \) -print -quit) - test -n "$package" - artifact_dir="$RUNNER_TEMP/lifecycle-artifacts/$TARGET_ID" - mkdir -p "$artifact_dir" - cp "$package" "$artifact_dir/new-$(basename "$package")" - - uses: actions/upload-artifact@v4 - with: - name: lifecycle-packages-${{ matrix.target }} - path: ${{ runner.temp }}/lifecycle-artifacts/${{ matrix.target }}/ - if-no-files-found: error - retention-days: 14 - - validate: - name: ${{ matrix.target }} lifecycle VM - needs: build - strategy: - fail-fast: false - matrix: - include: - - target: ubuntu2604-amd64 - vm_label: tako-vm-ubuntu2604-amd64 - - target: fedora44-amd64 - vm_label: tako-vm-fedora44-amd64 - runs-on: [self-hosted, linux, "${{ matrix.vm_label }}"] - timeout-minutes: 45 - env: - TARGET_ID: ${{ matrix.target }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: lifecycle-packages-${{ matrix.target }} - path: lifecycle-packages - - name: Run lifecycle validation in disposable VM - run: | - set -euo pipefail - old_package=$(find lifecycle-packages -type f -name 'old-*' -print -quit) - new_package=$(find lifecycle-packages -type f -name 'new-*' -print -quit) - test -n "$old_package" - test -n "$new_package" - sudo env \ - GITHUB_SHA="$GITHUB_SHA" \ - TAKO_VM_TARGET="$TARGET_ID" \ - TAKO_VM_PACKAGE="$GITHUB_WORKSPACE/$new_package" \ - TAKO_VM_OLD_PACKAGE="$GITHUB_WORKSPACE/$old_package" \ - TAKO_VM_EVIDENCE_DIR="$GITHUB_WORKSPACE/lifecycle-evidence/$TARGET_ID" \ - TAKO_VM_MANIFEST="$GITHUB_WORKSPACE/apps/backend/packaging/targets.json" \ - "$GITHUB_WORKSPACE/apps/backend/packaging/vm-test.sh" - - uses: actions/upload-artifact@v4 - if: ${{ always() }} - with: - name: lifecycle-evidence-${{ matrix.target }} - path: lifecycle-evidence/${{ matrix.target }}/ - if-no-files-found: warn - retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7fc6e13 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + name: Resolve version + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + prerelease: ${{ steps.resolve.outputs.prerelease }} + steps: + - name: Validate tag and resolve release type + id: resolve + run: | + set -euo pipefail + if [ "${{ github.event_name }}" != push ]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + tag="${{ github.ref_name }}" + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "tag $tag does not match vMAJOR.MINOR.PATCH[-suffix]" >&2 + exit 1 + fi + if printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-'; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + ci: + name: Required CI + uses: ./.github/workflows/ci.yml + + native-packages: + name: Native packages + needs: ci + uses: ./.github/workflows/native-packages.yml + + publish: + name: Assemble and publish + needs: [native-packages, version] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - uses: actions/download-artifact@v4 + with: + pattern: package-* + path: incoming + - name: Assemble every manifest package + run: bun tools/release-assemble incoming release + - name: Generate release checksums + working-directory: release + run: | + set -euo pipefail + checksums_tmp=$(mktemp) + trap 'rm -f "$checksums_tmp"' EXIT + find . -maxdepth 1 -type f ! -name checksums.txt -print0 \ + | sort -z \ + | xargs -0 sha256sum > "$checksums_tmp" + mv "$checksums_tmp" checksums.txt + - uses: actions/upload-artifact@v4 + with: + name: release-packages + path: release/ + if-no-files-found: error + retention-days: 30 + - name: Publish release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + prerelease: ${{ needs.version.outputs.prerelease == 'true' }} + generate_release_notes: true + fail_on_unmatched_files: true + files: release/* diff --git a/.github/workflows/vm-validation.yml b/.github/workflows/vm-validation.yml deleted file mode 100644 index 3a36bc7..0000000 --- a/.github/workflows/vm-validation.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Packaging VM validation - -on: - workflow_call: - -permissions: - contents: read - actions: read - -jobs: - manifest: - name: Expand VM target manifest - runs-on: ubuntu-24.04 - outputs: - matrix: ${{ steps.targets.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - id: targets - run: | - matrix=$(bun -e ' - const manifest = await Bun.file("apps/backend/packaging/targets.json").json(); - console.log(JSON.stringify({include: manifest.targets.map((target) => ({ - id: target.id, - arch: target.arch, - format: target.package_format, - vm_label: target.vm_label, - }))})); - ') - echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - - validate: - name: ${{ matrix.id }} disposable VM - needs: manifest - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.manifest.outputs.matrix) }} - # These labels are an explicit operator prerequisite. A beta release stays - # blocked while a target VM runner is missing or offline. - runs-on: [self-hosted, linux, "${{ matrix.vm_label }}"] - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - name: Download native package - uses: actions/download-artifact@v4 - with: - name: package-${{ matrix.id }} - path: package - - name: Run installed-system smoke validation - env: - TARGET_ID: ${{ matrix.id }} - PACKAGE_FORMAT: ${{ matrix.format }} - run: | - case "$PACKAGE_FORMAT" in - deb) package=$(find package -type f -name '*.deb' -print -quit) ;; - rpm) package=$(find package -type f -name '*.rpm' -print -quit) ;; - archlinux) package=$(find package -type f -name '*.pkg.tar.zst' -print -quit) ;; - *) echo "unknown package format: $PACKAGE_FORMAT" >&2; exit 2 ;; - esac - test -n "$package" - sudo env \ - GITHUB_SHA="$GITHUB_SHA" \ - TAKO_VM_TARGET="$TARGET_ID" \ - TAKO_VM_PACKAGE="$GITHUB_WORKSPACE/$package" \ - TAKO_VM_EVIDENCE_DIR="$GITHUB_WORKSPACE/vm-evidence" \ - TAKO_VM_MANIFEST="$GITHUB_WORKSPACE/apps/backend/packaging/targets.json" \ - "$GITHUB_WORKSPACE/apps/backend/packaging/vm-test.sh" - - name: Upload VM evidence - uses: actions/upload-artifact@v4 - with: - name: vm-evidence-${{ matrix.id }} - path: vm-evidence/ - if-no-files-found: error - retention-days: 30 diff --git a/apps/backend/packaging/README.md b/apps/backend/packaging/README.md index 54ee009..6ea6b33 100644 --- a/apps/backend/packaging/README.md +++ b/apps/backend/packaging/README.md @@ -29,9 +29,9 @@ Password changes and administrative resets use the host's standard `passwd` PAM ## GoReleaser packages -Local packaging requires installed Bun dependencies, Go 1.26+, GoReleaser 2.x, a C compiler, and the host's PAM development headers and linker files. `bun run package:check` validates those capabilities without assuming a distribution package manager. The default package command cross-packages every manifest distro target matching the host architecture; use `TAKO_PACKAGE_TARGET` to select one native manifest entry. The complete beta matrix and its external VM prerequisites are documented in [`RELEASE.md`](RELEASE.md). +Local packaging requires installed Bun dependencies, Go 1.26+, GoReleaser 2.x, a C compiler, and the host's PAM development headers and linker files. `bun run package:check` validates those capabilities without assuming a distribution package manager. The default package command cross-packages every manifest distro target matching the host architecture; use `TAKO_PACKAGE_TARGET` to select one native manifest entry. The complete matrix is documented in [`RELEASE.md`](RELEASE.md). -Run `bun run package` from repository root to validate packaging and emit one package and binary per manifest distro for the host architecture, plus `artifacts.json` and `checksums.txt` in `dist/`. `TAKO_PACKAGE_TARGET= bun run package` emits one native package and binary. GoReleaser's embedded nFPM packager creates packages without requiring a separate package-builder tool. Snapshot packages do not publish releases. The beta workflow builds each manifest target in its own target image, signs the final checksum manifest, records a dependency inventory, and emits provenance only after the CI and VM evidence gates pass. Local cross-packages use the host's CGO, glibc, and PAM toolchain; native matrix builds provide target-runtime compatibility validation. +Run `bun run package` from repository root to validate packaging and emit one package and binary per manifest distro for the host architecture, plus `artifacts.json` and `checksums.txt` in `dist/`. `TAKO_PACKAGE_TARGET= bun run package` emits one native package and binary. GoReleaser's embedded nFPM packager creates packages without requiring a separate package-builder tool. Snapshot packages do not publish releases. The `release.yml` workflow builds each manifest target in its own target image on GitHub-hosted runners and publishes a GitHub release only after CI and every native package build pass; a hyphenated tag suffix marks it prerelease, a clean version marks it stable. Local cross-packages use the host's CGO, glibc, and PAM toolchain; native matrix builds provide target-runtime compatibility validation. Generated packages install full production runtime files: executable, four systemd units, sysusers and tmpfiles definitions, Polkit policy, the example TOML configuration under `/usr/share/doc/tako/`, exactly one matching distro branding asset under `/usr/share/tako/branding/`, and a distribution-specific PAM stack at `/etc/pam.d/tako`. Debian and Ubuntu use `pam/tako.debian`; Fedora, RHEL, Rocky, and AlmaLinux use `pam/tako.redhat`; openSUSE uses `pam/tako.opensuse`; Arch Linux uses the generic `pam/tako` policy. PAM files are package-managed as `config|noreplace`. Packages do not install a live `/etc/tako/config.toml`, sudoers example, smoke test, or operational README. @@ -76,4 +76,4 @@ Host-integrated development (`tools/tako-host`, dashboard overlay) is documented The installed-system smoke seam is `packaging/smoke-test.sh`. It checks binary presence, socket activation, static service enablement, service identities, TLS reachability, and (when `TAKO_SMOKE_USER`/`TAKO_SMOKE_PASSWORD` are provided) a real PAM login. `TAKO_SMOKE_USER` must be a **non-root** UNIX account; root-only success hides the user-bridge spawn path. It stops activated service processes at completion while leaving sockets running. -Run `packaging/lifecycle-test.sh OLD_PACKAGE NEW_PACKAGE` only inside a disposable VM with `TAKO_DISPOSABLE_HOST=1`. It destructively exercises fresh install, active upgrade, disabled/stopped preservation, administrator masks, removal, reinstall, purge, and trigger-limit health for two `.deb`, two `.rpm`, or two Arch `.pkg.tar.zst` files. Dependencies must already be available in the VM. `packaging/vm-test.sh` records evidence only after this harness or the installed-system smoke test actually succeeds. +Run `packaging/lifecycle-test.sh OLD_PACKAGE NEW_PACKAGE` only inside a disposable VM with `TAKO_DISPOSABLE_HOST=1`. It destructively exercises fresh install, active upgrade, disabled/stopped preservation, administrator masks, removal, reinstall, purge, and trigger-limit health for two `.deb`, two `.rpm`, or two Arch `.pkg.tar.zst` files. Dependencies must already be available in the VM. diff --git a/apps/backend/packaging/RELEASE.md b/apps/backend/packaging/RELEASE.md index 7d8749e..f0f8910 100644 --- a/apps/backend/packaging/RELEASE.md +++ b/apps/backend/packaging/RELEASE.md @@ -1,6 +1,6 @@ -# Beta release process +# Release process -`targets.json` is the source of truth for the beta support matrix. It currently +`targets.json` is the source of truth for the support matrix. It currently contains the 15 native combinations below: | Family | Release | Architectures | @@ -45,27 +45,18 @@ The `native-packages.yml` workflow expands the manifest and builds each target in its declared image. The arm64 entries require an arm64 GitHub runner; an amd64 runner cannot silently satisfy them. The RHEL image is the public UBI image named by the manifest. If an image tag or hosted runner is unavailable, -the target remains pending or fails and the beta release stays blocked. - -The reusable `vm-validation.yml` workflow consumes native artifacts from the -same beta run (use the beta workflow for manual end-to-end validation). It runs `packaging/vm-test.sh` on a disposable -VM runner carrying the `vm_label` from the manifest. These labels describe an -operator-provided runner contract; this repository does not contain VM hosts, -credentials, or evidence that a VM has run. Each successful invocation writes -one release input record containing the tested package digest. A failed or -missing record is a hard failure. - -PR and push CI run on GitHub-hosted runners. Privileged VM integration runs -on its schedule, by manual dispatch, and as a required beta release gate; it -is not a PR gate because it requires operator-provided disposable runners. - -The beta workflow publishes only after all required CI jobs, VM integration, all native package -jobs, and every manifest target's VM evidence pass. It verifies each package digest against its VM evidence, then assembles packages, -writes `dependency-inventory.json`, creates `checksums.txt`, signs that checksum -manifest with keyless Sigstore signing, and emits GitHub build provenance. The -workflow uses the repository's ephemeral `GITHUB_TOKEN`; no package registry, -VM host, signing key, password, or deployment credential is stored in this -repository. +the target remains pending or fails and the release stays blocked. + +The `release.yml` workflow runs CI, builds every manifest target with +`native-packages.yml` on GitHub-hosted runners, assembles one package per +target, and writes `checksums.txt`. It publishes a GitHub release for any +`v*` tag; manual dispatch builds and validates without publishing. The tag +is the release type: a hyphenated suffix (`v0.3.0-beta1`, `v0.3.0-alpha2`, +`v0.3.0-rc1`) publishes as a prerelease, a clean version (`v1.2.0`) +publishes as a stable release. Malformed tags fail fast before any packages +build. There is no VM evidence gate, no signing, and no provenance +attestation; validation of installed systems happens separately through the +lifecycle harness inside a disposable VM you provide. Run the Arch lifecycle harness inside a disposable Arch VM when both package versions are available: diff --git a/apps/backend/packaging/targets.json b/apps/backend/packaging/targets.json index 49d051d..c67ae56 100644 --- a/apps/backend/packaging/targets.json +++ b/apps/backend/packaging/targets.json @@ -35,8 +35,7 @@ "package_format": "deb", "package_name": "debian_amd64", "pam_source": "tako.debian", - "branding": "debian", - "vm_label": "tako-vm-debian13-amd64" + "branding": "debian" }, { "id": "debian13-arm64", @@ -52,8 +51,7 @@ "package_format": "deb", "package_name": "debian_arm64", "pam_source": "tako.debian", - "branding": "debian", - "vm_label": "tako-vm-debian13-arm64" + "branding": "debian" }, { "id": "ubuntu2604-amd64", @@ -69,8 +67,7 @@ "package_format": "deb", "package_name": "ubuntu_amd64", "pam_source": "tako.debian", - "branding": "ubuntu", - "vm_label": "tako-vm-ubuntu2604-amd64" + "branding": "ubuntu" }, { "id": "ubuntu2604-arm64", @@ -86,8 +83,7 @@ "package_format": "deb", "package_name": "ubuntu_arm64", "pam_source": "tako.debian", - "branding": "ubuntu", - "vm_label": "tako-vm-ubuntu2604-arm64" + "branding": "ubuntu" }, { "id": "fedora44-amd64", @@ -102,8 +98,7 @@ "package_format": "rpm", "package_name": "fedora_amd64", "pam_source": "tako.redhat", - "branding": "fedora", - "vm_label": "tako-vm-fedora44-amd64" + "branding": "fedora" }, { "id": "fedora44-arm64", @@ -118,8 +113,7 @@ "package_format": "rpm", "package_name": "fedora_arm64", "pam_source": "tako.redhat", - "branding": "fedora", - "vm_label": "tako-vm-fedora44-arm64" + "branding": "fedora" }, { "id": "rhel102-amd64", @@ -134,8 +128,7 @@ "package_format": "rpm", "package_name": "rhel_amd64", "pam_source": "tako.redhat", - "branding": "rhel", - "vm_label": "tako-vm-rhel102-amd64" + "branding": "rhel" }, { "id": "rhel102-arm64", @@ -150,8 +143,7 @@ "package_format": "rpm", "package_name": "rhel_arm64", "pam_source": "tako.redhat", - "branding": "rhel", - "vm_label": "tako-vm-rhel102-arm64" + "branding": "rhel" }, { "id": "rocky102-amd64", @@ -166,8 +158,7 @@ "package_format": "rpm", "package_name": "rocky_amd64", "pam_source": "tako.redhat", - "branding": "rocky", - "vm_label": "tako-vm-rocky102-amd64" + "branding": "rocky" }, { "id": "rocky102-arm64", @@ -182,8 +173,7 @@ "package_format": "rpm", "package_name": "rocky_arm64", "pam_source": "tako.redhat", - "branding": "rocky", - "vm_label": "tako-vm-rocky102-arm64" + "branding": "rocky" }, { "id": "almalinux102-amd64", @@ -198,8 +188,7 @@ "package_format": "rpm", "package_name": "almalinux_amd64", "pam_source": "tako.redhat", - "branding": "almalinux", - "vm_label": "tako-vm-almalinux102-amd64" + "branding": "almalinux" }, { "id": "almalinux102-arm64", @@ -214,8 +203,7 @@ "package_format": "rpm", "package_name": "almalinux_arm64", "pam_source": "tako.redhat", - "branding": "almalinux", - "vm_label": "tako-vm-almalinux102-arm64" + "branding": "almalinux" }, { "id": "opensuse160-amd64", @@ -231,8 +219,7 @@ "package_format": "rpm", "package_name": "opensuse_amd64", "pam_source": "tako.opensuse", - "branding": "opensuse", - "vm_label": "tako-vm-opensuse160-amd64" + "branding": "opensuse" }, { "id": "opensuse160-arm64", @@ -248,8 +235,7 @@ "package_format": "rpm", "package_name": "opensuse_arm64", "pam_source": "tako.opensuse", - "branding": "opensuse", - "vm_label": "tako-vm-opensuse160-arm64" + "branding": "opensuse" }, { "id": "arch-rolling-amd64", @@ -264,8 +250,7 @@ "package_format": "archlinux", "package_name": "archlinux_amd64", "pam_source": "tako", - "branding": "archlinux", - "vm_label": "tako-vm-arch-rolling-amd64" + "branding": "archlinux" } ] } diff --git a/apps/backend/packaging/vm-test.sh b/apps/backend/packaging/vm-test.sh deleted file mode 100755 index 6ed3702..0000000 --- a/apps/backend/packaging/vm-test.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -# This is the only script that writes a VM validation result. It is intended to -# run as root in a disposable, booted target VM. The release workflow consumes -# the result and refuses to publish when a target has no passing evidence. -target_id="${TAKO_VM_TARGET:-}" -new_package="${TAKO_VM_PACKAGE:-}" -old_package="${TAKO_VM_OLD_PACKAGE:-}" -evidence_dir="${TAKO_VM_EVIDENCE_DIR:-vm-evidence}" -manifest="${TAKO_VM_MANIFEST:-apps/backend/packaging/targets.json}" -evidence_file="$evidence_dir/${target_id:-unknown}.json" -status=failed -started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) -log_file="" - -write_evidence() { - local exit_code=$? - mkdir -p "$evidence_dir" - python3 - "$evidence_file" "$target_id" "$status" "$exit_code" "$started_at" "$new_package" "$old_package" "$log_file" <<'PY' -import hashlib -import json -import os -import platform -import sys -from datetime import datetime, timezone - -path, target, status, exit_code, started, new_package, old_package, log_file = sys.argv[1:] - -def digest(value): - if not value: - return None - try: - with open(value, "rb") as stream: - return hashlib.sha256(stream.read()).hexdigest() - except OSError: - return None - -payload = { - "schema": 1, - "target": target, - "status": status, - "exit_code": int(exit_code), - "started_at": started, - "finished_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), - "commit": os.environ.get("GITHUB_SHA") or os.environ.get("TAKO_VM_COMMIT"), - "host": { - "machine": platform.machine(), - "system": platform.system(), - "release": platform.release(), - }, - "package": { - "new": os.path.basename(new_package) if new_package else None, - "new_sha256": digest(new_package), - "old": os.path.basename(old_package) if old_package else None, - "old_sha256": digest(old_package), - }, - "log": os.path.basename(log_file) if log_file else None, -} -with open(path, "w", encoding="utf-8") as stream: - json.dump(payload, stream, sort_keys=True, indent=2) - stream.write("\n") -PY - return "$exit_code" -} -trap write_evidence EXIT - -if [[ "$EUID" -ne 0 ]]; then - echo "vm-test: run as root in a disposable target VM" >&2 - exit 2 -fi -if [[ -z "$target_id" || -z "$new_package" ]]; then - echo "usage: TAKO_VM_TARGET=TARGET TAKO_VM_PACKAGE=PACKAGE $0" >&2 - exit 2 -fi -if [[ ! -f "$manifest" || ! -f "$new_package" ]]; then - echo "vm-test: manifest and package must be present in the VM" >&2 - exit 2 -fi - -mkdir -p "$evidence_dir" -log_file="$evidence_dir/${target_id}.log" -: >"$log_file" -exec > >(tee -a "$log_file") 2>&1 - -target_json=$(python3 - "$manifest" "$target_id" <<'PY' -import json -import sys -with open(sys.argv[1], encoding="utf-8") as stream: - manifest = json.load(stream) -for target in manifest["targets"]: - if target["id"] == sys.argv[2]: - print(json.dumps(target)) - break -else: - raise SystemExit("unknown target") -PY -) - -read_target() { - python3 -c 'import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])' "$target_json" "$1" -} - -expected_distro=$(read_target distribution) -expected_release=$(read_target release) -expected_arch=$(read_target goarch) -package_format=$(read_target package_format) -host_arch=$(go env GOARCH 2>/dev/null || true) -[[ -n "$host_arch" ]] || host_arch=$(case "$(uname -m)" in x86_64) echo amd64;; aarch64) echo arm64;; *) echo unknown;; esac) -[[ "$host_arch" == "$expected_arch" ]] || { echo "vm-test: host arch $host_arch does not match $expected_arch" >&2; exit 1; } - -declare -A os_release=() -while IFS='=' read -r key value; do - value=${value%\"} - value=${value#\"} - os_release["$key"]="$value" -done < /etc/os-release -host_distro=${os_release[ID]:-} -case "$host_distro" in - arch) host_distro=archlinux ;; - opensuse-leap|opensuse-tumbleweed|sles) host_distro=opensuse ;; -esac -[[ "$host_distro" == "$expected_distro" ]] || { echo "vm-test: host distro $host_distro does not match $expected_distro" >&2; exit 1; } -if [[ "$expected_release" != rolling ]]; then - host_release=${os_release[VERSION_ID]:-} - [[ "$host_release" == "$expected_release" || "$host_release" == "$expected_release."* ]] || { - echo "vm-test: host release $host_release does not match $expected_release" >&2 - exit 1 - } -fi - -if [[ -n "$old_package" ]]; then - [[ -f "$old_package" ]] || { echo "vm-test: old package does not exist" >&2; exit 1; } - TAKO_DISPOSABLE_HOST=1 apps/backend/packaging/lifecycle-test.sh "$old_package" "$new_package" -else - case "$package_format" in - deb) dpkg --install "$new_package" ;; - rpm) rpm --upgrade --verbose --hash "$new_package" ;; - archlinux) pacman --noconfirm --upgrade "$new_package" ;; - *) echo "vm-test: unsupported package format $package_format" >&2; exit 1 ;; - esac - TAKO_BINARY=/usr/bin/tako apps/backend/packaging/smoke-test.sh -fi - -status=passed -echo "VM validation passed for $target_id" diff --git a/tools/package-check b/tools/package-check index 1d52ef4..9e809e4 100755 --- a/tools/package-check +++ b/tools/package-check @@ -53,7 +53,7 @@ const config = Bun.YAML.parse(await Bun.file(".goreleaser.yaml").text()); const builds = new Map((config.builds ?? []).map((build) => [build.id, build])); const packageIds = new Set((config.nfpms ?? []).map((pkg) => pkg.id)); for (const target of targets) { - for (const key of ["id", "distribution", "release", "image", "arch", "goarch", "build_tag", "build_id", "package_id", "package_format", "package_name", "pam_source", "branding", "vm_label"]) { + for (const key of ["id", "distribution", "release", "image", "arch", "goarch", "build_tag", "build_id", "package_id", "package_format", "package_name", "pam_source", "branding"]) { if (typeof target[key] !== "string" || target[key] === "") throw new Error(`${target.id ?? "target"} is missing ${key}`); } if (ids.has(target.id)) throw new Error(`duplicate target id: ${target.id}`); diff --git a/tools/release-dependencies b/tools/release-dependencies deleted file mode 100755 index 21aabe1..0000000 --- a/tools/release-dependencies +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bun - -const output = process.argv[2] ?? "dependency-inventory.json"; -const run = (command, cwd = ".") => { - const result = Bun.spawnSync({ cmd: command, cwd, stdout: "pipe", stderr: "pipe" }); - if (result.exitCode !== 0) throw new Error(`${command.join(" ")} failed: ${result.stderr.toString()}`); - return result.stdout.toString(); -}; - -const moduleText = run(["go", "list", "-m", "-json", "all"], "apps/backend"); -const moduleRecords = []; -let start = -1; -let depth = 0; -let quoted = false; -let escaped = false; -for (let index = 0; index < moduleText.length; index += 1) { - const character = moduleText[index]; - if (quoted) { - if (escaped) escaped = false; - else if (character === "\\") escaped = true; - else if (character === '"') quoted = false; - continue; - } - if (character === '"') { - quoted = true; - continue; - } - if (character === "{") { - if (depth === 0) start = index; - depth += 1; - } else if (character === "}") { - depth -= 1; - if (depth === 0 && start >= 0) { - moduleRecords.push(JSON.parse(moduleText.slice(start, index + 1))); - start = -1; - } - } -} -const modules = moduleRecords - .map(({ Path, Version, Sum, GoMod, GoModSum, Main }) => ({ - path: Path, - version: Version ?? null, - sum: Sum ?? null, - go_mod: GoMod ?? null, - go_mod_sum: GoModSum ?? null, - main: Boolean(Main), - })); - -const lockfile = Bun.file("bun.lock"); -const lockBytes = await lockfile.arrayBuffer(); -const lockHash = new Bun.CryptoHasher("sha256").update(lockBytes).digest("hex"); -const goVersion = run(["go", "version"]).trim(); -const inventory = { - schema: 1, - generated_at: new Date().toISOString(), - commit: process.env.GITHUB_SHA ?? null, - go: { version: goVersion, modules }, - javascript: { lockfile: "bun.lock", lockfile_sha256: lockHash }, -}; -await Bun.write(output, `${JSON.stringify(inventory, null, 2)}\n`); -console.log(`dependency inventory written to ${output}`); diff --git a/tools/release-gate b/tools/release-gate deleted file mode 100755 index 2989a27..0000000 --- a/tools/release-gate +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bun - -const manifestPath = process.env.TAKO_RELEASE_MANIFEST ?? "apps/backend/packaging/targets.json"; -const evidenceDir = process.argv[2] ?? process.env.TAKO_VM_EVIDENCE_DIR ?? "vm-evidence"; -const packageDir = process.argv[3] ?? process.env.TAKO_RELEASE_PACKAGE_DIR ?? ""; -const expectedCommit = process.argv[4] ?? process.env.TAKO_VM_COMMIT ?? process.env.GITHUB_SHA ?? ""; - -const fail = (message) => { - console.error(`release gate: ${message}`); - process.exit(1); -}; - -const manifest = await Bun.file(manifestPath).json().catch(() => null); -if (!manifest || !Array.isArray(manifest.targets) || manifest.targets.length === 0) fail(`cannot read targets from ${manifestPath}`); - -const targetIds = new Set(); -for (const target of manifest.targets) { - if (typeof target.id !== "string" || target.id === "" || targetIds.has(target.id)) fail(`manifest contains an invalid or duplicate target id: ${target.id}`); - targetIds.add(target.id); -} - -if ((await Bun.$`test -d ${evidenceDir}`.quiet().nothrow()).exitCode !== 0) fail(`evidence directory does not exist: ${evidenceDir}`); -if (packageDir && (await Bun.$`test -d ${packageDir}`.quiet().nothrow()).exitCode !== 0) fail(`package directory does not exist: ${packageDir}`); - -const evidence = new Map(); -for await (const path of new Bun.Glob("**/*.json").scan({ cwd: evidenceDir, absolute: true })) { - const value = await Bun.file(path).json().catch(() => null); - if (!value || typeof value.target !== "string") continue; - if (evidence.has(value.target)) fail(`duplicate evidence for ${value.target}`); - evidence.set(value.target, value); -} - -for (const target of manifest.targets) { - const value = evidence.get(target.id); - if (!value) fail(`missing VM evidence for ${target.id}`); - if (value.status !== "passed") fail(`${target.id} did not pass (${value.status ?? "missing status"})`); - if (value.exit_code !== 0) fail(`${target.id} reported exit code ${value.exit_code}`); - if (expectedCommit && value.commit !== expectedCommit) fail(`${target.id} evidence is for ${value.commit ?? "an unknown commit"}, expected ${expectedCommit}`); - if (!value.package?.new_sha256) fail(`${target.id} evidence does not identify the tested package digest`); - if (packageDir) { - const suffix = { - deb: ".deb", - rpm: ".rpm", - archlinux: ".pkg.tar.zst", - }[target.package_format]; - const candidates = []; - for await (const path of new Bun.Glob("**/*").scan({ cwd: packageDir, absolute: true, onlyFiles: true })) { - if (path.endsWith(`_${target.package_name}${suffix}`)) candidates.push(path); - } - if (candidates.length !== 1) fail(`${target.id} has ${candidates.length} assembled package candidates`); - const hash = new Bun.CryptoHasher("sha256").update(await Bun.file(candidates[0]).arrayBuffer()).digest("hex"); - if (hash !== value.package.new_sha256) fail(`${target.id} evidence digest does not match the assembled package`); - } -} - -for (const [target] of evidence) if (!targetIds.has(target)) fail(`evidence contains an unknown target: ${target}`); - -console.log(`release gate passed: ${targetIds.size} manifest targets have passing VM evidence`); diff --git a/tools/release-gate.test.ts b/tools/release-gate.test.ts deleted file mode 100644 index cbed3e7..0000000 --- a/tools/release-gate.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const directories: string[] = []; -afterEach(async () => { - await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); -}); - -async function fixture() { - const root = await mkdtemp(join(tmpdir(), "tako-release-gate-")); - directories.push(root); - const manifest = join(root, "manifest.json"); - const evidence = join(root, "evidence", "target.json"); - const artifact = join(root, "packages", "tako_1.0_ubuntu_amd64.deb"); - await Bun.write(manifest, JSON.stringify({ targets: [{ - id: "ubuntu-amd64", package_name: "ubuntu_amd64", package_format: "deb", - }] })); - await Bun.write(artifact, "tested package"); - const record = { - target: "ubuntu-amd64", status: "passed", exit_code: 0, commit: "tested-commit", - package: { new_sha256: new Bun.CryptoHasher("sha256").update("tested package").digest("hex") }, - }; - await Bun.write(evidence, JSON.stringify(record)); - return { root, manifest, evidence, artifact, record }; -} - -async function gate(value: Awaited>) { - const result = Bun.spawnSync({ - cmd: [process.execPath, join(import.meta.dir, "release-gate"), - join(value.root, "evidence"), join(value.root, "packages"), "tested-commit"], - env: { ...process.env, TAKO_RELEASE_MANIFEST: value.manifest }, - stdout: "pipe", stderr: "pipe", - }); - return { code: result.exitCode, error: result.stderr.toString() }; -} - -test("accepts the exact package validated by the VM", async () => { - expect((await gate(await fixture())).code).toBe(0); -}); - -test("rejects a substituted release package", async () => { - const value = await fixture(); - await Bun.write(value.artifact, "untested package"); - const result = await gate(value); - expect(result.code).toBe(1); - expect(result.error).toContain("evidence digest does not match"); -}); - -test("rejects evidence from another commit", async () => { - const value = await fixture(); - await Bun.write(value.evidence, JSON.stringify({ ...value.record, commit: "old-commit" })); - const result = await gate(value); - expect(result.code).toBe(1); - expect(result.error).toContain("expected tested-commit"); -}); - -test("rejects missing VM evidence", async () => { - const value = await fixture(); - await rm(value.evidence); - const result = await gate(value); - expect(result.code).toBe(1); - expect(result.error).toContain("missing VM evidence"); -});