diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index 5850c7f6d..000000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,291 +0,0 @@ -# GENERATED BY: cargo xtask ci -# DO NOT EDIT - edit xtask/src/ci.rs instead - ---- -name: CI -"on": - push: - tags: - - v* - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: {} -permissions: - contents: read -env: - CARGO_TERM_COLOR: always -jobs: - forgejo-linux-x64: - name: Forgejo linux-x64 - runs-on: - - linux-x86_64-trusted - container: - image: "code.vixen.rs/vixen/vixen-ci:latest" - volumes: - - "/srv/ci/cache/dodeca:/srv/ci/cache/dodeca" - timeout-minutes: 90 - env: - HOME: /srv/ci/cache/dodeca/home - CARGO_HOME: /srv/ci/cache/dodeca/cargo - CARGO_INCREMENTAL: "1" - FORGEJO_TOKEN: ${{ github.token }} - RUST_BACKTRACE: "1" - steps: - - name: Prepare Vixen CI - run: vixen-ci prepare - env: - DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} - - name: Checkout - run: vixen-ci checkout - - name: Prepare stable source - run: |- - vixen-ci stable-src dodeca-linux-x64 \ - --exclude=/crates/dodeca-devtools/pkg/ \ - --exclude=/crates/dodeca-search-wasm/pkg/ - export STABLE_SRC="$HOME/.cache/vixen/dodeca-linux-x64-src" - export CARGO_TARGET_DIR="$HOME/.cache/vixen/dodeca-linux-x64-target" - cd "$STABLE_SRC" - rm -rf target - ln -s "$CARGO_TARGET_DIR" target - - echo "STABLE_SRC=$STABLE_SRC" - echo "CARGO_TARGET_DIR=$CARGO_TARGET_DIR" - - name: Build and test - run: | - set -euo pipefail - cd "$STABLE_SRC" - cargo xtask ci-forgejo --check - - rustup target add wasm32-unknown-unknown - if ! command -v wasm-pack >/dev/null 2>&1; then - cargo install wasm-pack --locked - fi - npm install -g pnpm - scripts/build-browser-assets.sh - cargo nextest run - cargo xtask integration - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo build --release - actual="$(target/release/ddc --version)" - echo "$actual" - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi - fi - rm -rf dist - mkdir -p dist - cp target/release/ddc dist/ - chmod +x dist/ddc - #!/bin/bash - set -euo pipefail - - echo 'Verifying all required artifacts exist in dist/' - missing=0 - - if [[ ! -x dist/ddc ]]; then - echo '❌ MISSING: ddc' - missing=1 - else - echo '✓ ddc' - fi - - if [[ $missing -eq 1 ]]; then - echo '' - echo 'ERROR: Some required binaries are missing!' - echo 'This usually means a ddc build job failed or artifact upload was incomplete.' - exit 1 - fi - - echo '' - echo 'All artifacts verified.' - - DODECA_BIN="$STABLE_SRC/target/release/ddc" \ - bash -lc 'cd "$STABLE_SRC/browser-tests" && npm ci && npx playwright install chromium --with-deps && npm test' - shell: bash - - name: Assemble archive - run: |- - set -euo pipefail - cd "$STABLE_SRC" - bash scripts/assemble-archive.sh x86_64-unknown-linux-gnu - mkdir -p "$GITHUB_WORKSPACE/dist" - cp "dodeca-x86_64-unknown-linux-gnu.tar.xz" "$GITHUB_WORKSPACE/dist/" - ls -la "$GITHUB_WORKSPACE/dist/" - shell: bash - - name: Upload artifact - uses: "https://data.forgejo.org/actions/upload-artifact@v3" - with: - name: build-linux-x64 - path: dist/dodeca-x86_64-unknown-linux-gnu.tar.xz - forgejo-macos-arm64: - name: Forgejo macos-arm64 - runs-on: - - mac - timeout-minutes: 90 - env: - CARGO_INCREMENTAL: "0" - FORGEJO_TOKEN: ${{ github.token }} - RUST_BACKTRACE: "1" - steps: - - name: Install vixen-ci - run: |- - mkdir -p "$RUNNER_TEMP/vixen-ci" - curl -fsSL https://vixen-misc.s3-website.fr-par.scw.cloud/vixen-ci/latest/vixen-ci -o "$RUNNER_TEMP/vixen-ci/vixen-ci" - chmod +x "$RUNNER_TEMP/vixen-ci/vixen-ci" - echo "$RUNNER_TEMP/vixen-ci" >> "$GITHUB_PATH" - - name: Prepare Vixen CI - run: vixen-ci prepare - env: - DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} - - name: Checkout - run: vixen-ci checkout - - name: Rust toolchain - run: vixen-ci rust-toolchain - - name: Install nextest - run: vixen-ci nextest - - name: Check rsync - run: rsync --version >/dev/null - - name: Prepare stable source - run: |- - vixen-ci stable-src dodeca-macos-arm64 \ - --exclude=/crates/dodeca-devtools/pkg/ \ - --exclude=/crates/dodeca-search-wasm/pkg/ - export STABLE_SRC="$HOME/.cache/vixen/dodeca-macos-arm64-src" - export CARGO_TARGET_DIR="$HOME/.cache/vixen/dodeca-macos-arm64-target" - cd "$STABLE_SRC" - rm -rf target - ln -s "$CARGO_TARGET_DIR" target - rm -rf "$CARGO_TARGET_DIR"/release/incremental "$CARGO_TARGET_DIR"/debug/incremental - - echo "STABLE_SRC=$STABLE_SRC" - echo "CARGO_TARGET_DIR=$CARGO_TARGET_DIR" - - name: Build and test - run: |+ - set -euo pipefail - cd "$STABLE_SRC" - - rustup target add wasm32-unknown-unknown - if ! command -v wasm-pack >/dev/null 2>&1; then - cargo install wasm-pack --locked - fi - command -v pnpm >/dev/null 2>&1 || npm install -g pnpm || corepack enable || true - scripts/build-browser-assets.sh - cargo nextest run - cargo xtask integration - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo build --release - actual="$(target/release/ddc --version)" - echo "$actual" - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi - fi - rm -rf dist - mkdir -p dist - cp target/release/ddc dist/ - chmod +x dist/ddc - #!/bin/bash - set -euo pipefail - - echo 'Verifying all required artifacts exist in dist/' - missing=0 - - if [[ ! -x dist/ddc ]]; then - echo '❌ MISSING: ddc' - missing=1 - else - echo '✓ ddc' - fi - - if [[ $missing -eq 1 ]]; then - echo '' - echo 'ERROR: Some required binaries are missing!' - echo 'This usually means a ddc build job failed or artifact upload was incomplete.' - exit 1 - fi - - echo '' - echo 'All artifacts verified.' - - shell: bash - - name: Assemble archive - run: |- - set -euo pipefail - cd "$STABLE_SRC" - if ! command -v xz >/dev/null 2>&1 && command -v brew >/dev/null 2>&1; then - HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew install xz - fi - bash scripts/assemble-archive.sh aarch64-apple-darwin - mkdir -p "$GITHUB_WORKSPACE/dist" - cp "dodeca-aarch64-apple-darwin.tar.xz" "$GITHUB_WORKSPACE/dist/" - ls -la "$GITHUB_WORKSPACE/dist/" - shell: bash - - name: Upload artifact - uses: "https://data.forgejo.org/actions/upload-artifact@v3" - with: - name: build-macos-arm64 - path: dist/dodeca-aarch64-apple-darwin.tar.xz - publish: - name: Publish release - runs-on: - - linux-x86_64-trusted - container: - image: "code.vixen.rs/vixen/vixen-ci:latest" - volumes: - - "/srv/ci/cache/dodeca:/srv/ci/cache/dodeca" - timeout-minutes: 30 - needs: - - forgejo-linux-x64 - - forgejo-macos-arm64 - if: "startsWith(github.ref, 'refs/tags/')" - env: - HOME: /srv/ci/cache/dodeca/home - FORGEJO_TOKEN: ${{ github.token }} - steps: - - name: Checkout repository - run: |- - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init -q - git remote remove origin 2>/dev/null || true - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - git -c http.extraHeader="Authorization: token ${FORGEJO_TOKEN}" fetch --depth 1 origin "${GITHUB_SHA}" - git checkout -q --detach FETCH_HEAD - shell: bash - - name: Download artifact - uses: "https://data.forgejo.org/actions/download-artifact@v3" - with: - name: build-linux-x64 - path: dist - - name: Download artifact - uses: "https://data.forgejo.org/actions/download-artifact@v3" - with: - name: build-macos-arm64 - path: dist - - name: Flatten artifact directories - run: find dist -mindepth 2 -type f -exec mv -t dist {} + 2>/dev/null || true; find dist -mindepth 1 -type d -empty -delete 2>/dev/null || true - - name: List artifacts - run: ls -la dist/ - - name: Prepare release artifacts - run: bash scripts/release.sh - shell: bash - - name: Publish to object storage - run: "bash scripts/publish-release.sh \"${GITHUB_REF_NAME}\"" - shell: bash - env: - AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.ACCESS_SECRET_KEY }} - AWS_DEFAULT_REGION: fr-par - AWS_EC2_METADATA_DISABLED: "true" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e6f1a3f8..b98e63903 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,7 @@ -# GENERATED BY: cargo xtask ci -# DO NOT EDIT - edit xtask/src/ci.rs instead - ---- name: CI -"on": + +on: push: - tags: - - v* branches: - main pull_request: @@ -15,453 +10,34 @@ name: CI merge_group: branches: - main - workflow_dispatch: {} -permissions: - contents: read -env: - CARGO_TERM_COLOR: always + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - check-ci: - name: Check CI up to date - runs-on: - - bearcove-ubuntu-24.04 - timeout-minutes: 10 + check: + name: Check + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Rust cache - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 with: cache-on-failure: "true" - cache-targets: "false" - - name: Check CI files are up to date - run: cargo xtask ci-github --check && cargo xtask ci-forgejo --check + - name: Check + run: cargo check --workspace --all-targets + clippy: name: Clippy - runs-on: - - bearcove-ubuntu-24.04 - timeout-minutes: 30 - continue-on-error: true + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - toolchain: stable components: clippy - targets: wasm32-unknown-unknown - - name: Rust cache - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@v2 with: cache-on-failure: "true" - cache-targets: "true" - name: Clippy - run: cargo clippy --all-features --all-targets -- -D warnings - check-macos: - name: Check macOS - runs-on: - - macos-15 - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: "true" - cache-targets: "true" - cache-bin: "false" - - name: Check macOS - run: cargo check --workspace --all-targets - check-windows: - name: Check Windows - runs-on: - - windows-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: "true" - cache-targets: "true" - - name: Check Windows - run: cargo check --workspace --all-targets - build-ddc-linux-x64: - name: Build ddc (linux-x64) - runs-on: - - bearcove-ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: latest - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: "true" - cache-targets: "true" - - name: Install pnpm - run: npm install -g pnpm - - name: Build ddc - run: |- - set -euo pipefail - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo build --release --bin ddc --verbose - actual="$(target/release/ddc --version)" - echo "$actual" - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi - fi - shell: bash - - name: Test ddc - run: |- - set -euo pipefail - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo test --release --bin ddc - shell: bash - - name: Build browser assets - run: |- - set -euo pipefail - scripts/build-browser-assets.sh - shell: bash - - name: Prepare ddc dist - run: |- - set -euo pipefail - rm -rf dist - mkdir -p dist - cp target/release/ddc dist/ddc - chmod +x dist/ddc - scripts/stage-browser-assets.sh dist/dodeca-assets - DODECA_ASSETS_DIR=dist/dodeca-assets dist/ddc assets --packaged --fail - ls -laR dist - shell: bash - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ddc-linux-x64 - path: dist - integration-linux-x64: - name: Integration (linux-x64) - runs-on: - - bearcove-ubuntu-24.04 - timeout-minutes: 30 - needs: - - build-ddc-linux-x64 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: "true" - cache-targets: "true" - - name: Build integration-tests - run: cargo build --package integration-tests - - name: Download ddc - uses: actions/download-artifact@v4 - with: - name: ddc-linux-x64 - path: dist - - name: Prepare artifacts - run: chmod +x dist/ddc && ls -la dist/ - - name: Verify artifacts - run: | - #!/bin/bash - set -euo pipefail - - echo 'Verifying all required artifacts exist in dist/' - missing=0 - - if [[ ! -x dist/ddc ]]; then - echo '❌ MISSING: ddc' - missing=1 - else - echo '✓ ddc' - fi - - if [[ $missing -eq 1 ]]; then - echo '' - echo 'ERROR: Some required binaries are missing!' - echo 'This usually means a ddc build job failed or artifact upload was incomplete.' - exit 1 - fi - - echo '' - echo 'All artifacts verified.' - - name: Run integration tests - run: cargo xtask integration --no-build - env: - DODECA_BIN: ${{ github.workspace }}/dist/ddc - DODECA_TEST_FIXTURES_DIR: ${{ github.workspace }}/crates/integration-tests/fixtures - browser-tests-linux-x64: - name: Browser Tests (linux-x64) - runs-on: - - bearcove-ubuntu-24.04 - timeout-minutes: 30 - needs: - - build-ddc-linux-x64 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Download ddc - uses: actions/download-artifact@v4 - with: - name: ddc-linux-x64 - path: dist - - name: Prepare artifacts - run: chmod +x dist/ddc && ls -la dist/ - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install browser test dependencies - run: cd browser-tests && npm ci && npx playwright install chromium --with-deps - - name: Run browser tests - run: cd browser-tests && npm test - env: - DODECA_BIN: ${{ github.workspace }}/dist/ddc - package-linux-x64: - name: Package (linux-x64) - runs-on: - - bearcove-ubuntu-24.04 - timeout-minutes: 30 - if: "startsWith(github.ref, 'refs/tags/')" - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: latest - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: "true" - cache-targets: "true" - - name: Install pnpm - run: npm install -g pnpm - - name: Build browser assets - run: |- - set -euo pipefail - scripts/build-browser-assets.sh - shell: bash - - name: Build ddc - run: |- - set -euo pipefail - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo build --release --bin ddc --verbose - actual="$(target/release/ddc --version)" - echo "$actual" - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi - fi - shell: bash - - name: Assemble archive - run: bash scripts/assemble-archive.sh x86_64-unknown-linux-gnu - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: build-linux-x64 - path: dodeca-x86_64-unknown-linux-gnu.tar.xz - package-macos-arm64: - name: Package (macos-arm64) - runs-on: - - blacksmith-12vcpu-macos-15 - timeout-minutes: 30 - if: "startsWith(github.ref, 'refs/tags/')" - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: latest - - name: Install pnpm - run: npm install -g pnpm - - name: Build browser assets - run: |- - set -euo pipefail - scripts/build-browser-assets.sh - shell: bash - - name: Build ddc - run: |- - set -euo pipefail - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo build --release --bin ddc --verbose - actual="$(target/release/ddc --version)" - echo "$actual" - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi - fi - shell: bash - - name: Install xz (macOS) - run: if ! command -v xz >/dev/null 2>&1 && command -v brew >/dev/null 2>&1; then HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew install xz; fi - - name: Assemble archive - run: bash scripts/assemble-archive.sh aarch64-apple-darwin - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: build-macos-arm64 - path: dodeca-aarch64-apple-darwin.tar.xz - package-windows-x64: - name: Package (windows-x64) - runs-on: - - blacksmith-8vcpu-windows-2025 - timeout-minutes: 30 - if: "startsWith(github.ref, 'refs/tags/')" - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install wasm-pack - uses: jetli/wasm-pack-action@v0.4.0 - with: - version: latest - - name: Install pnpm - run: npm install -g pnpm - - name: Build browser assets - run: |- - set -euo pipefail - scripts/build-browser-assets.sh - shell: bash - - name: Build ddc - run: |- - set -euo pipefail - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" - fi - cargo build --release --bin ddc --verbose - actual="$(target/release/ddc --version)" - echo "$actual" - if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi - fi - shell: bash - - name: Assemble archive - run: bash scripts/assemble-archive.sh x86_64-pc-windows-msvc - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: build-windows-x64 - path: dodeca-x86_64-pc-windows-msvc.zip - release: - name: Release - runs-on: - - ubuntu-latest - timeout-minutes: 30 - needs: - - integration-linux-x64 - - package-linux-x64 - - package-macos-arm64 - - package-windows-x64 - if: "startsWith(github.ref, 'refs/tags/')" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: dist - pattern: build-* - merge-multiple: "true" - - name: List artifacts (before flatten) - run: ls -laR dist/ - - name: Flatten artifact directories - run: find dist -mindepth 2 -type f -exec mv -t dist {} + 2>/dev/null || true; find dist -mindepth 1 -type d -empty -delete 2>/dev/null || true - - name: List artifacts (after flatten) - run: ls -la dist/ - - name: Create GitHub Release - run: |- - shopt -s nullglob - # Rename install.sh to dodeca-installer.sh for the release - cp install.sh dist/dodeca-installer.sh - gh release create "${{ github.ref_name }}" \ - --title "dodeca ${{ github.ref_name }}" \ - --generate-notes \ - dist/*.tar.xz dist/*.zip dist/dodeca-installer.sh - shell: bash - - name: Update Homebrew tap - run: "bash scripts/update-homebrew.sh \"${{ github.ref_name }}\"" - shell: bash + run: cargo clippy --workspace --all-targets --all-features -- -D warnings \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ca1fa75be..c0b0b542f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,7 +148,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -159,7 +159,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2817,7 +2817,7 @@ checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" dependencies = [ "cfg-if", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3171,7 +3171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3225,9 +3225,9 @@ dependencies = [ [[package]] name = "facet" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3e58a40571ddd336865ec9643ea43d5cf93c35ae4f5ad5d903dc4e427a2e03" +checksum = "db275d3c6e1957f362cd2a965caa699fe3134a1c78b1b696a8da8cfa60e101c5" dependencies = [ "autocfg", "facet-core", @@ -3236,9 +3236,9 @@ dependencies = [ [[package]] name = "facet-core" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c2ad271d54e76358f179bc5f75483c4ed025d1009a8599f299ac7bee99dbdd" +checksum = "7d33c21739c2d68ab096cdc48baf1bcebfbcc82496b96fddcb93aa96cd29775b" dependencies = [ "autocfg", "bytes", @@ -3249,23 +3249,24 @@ dependencies = [ "impls", "indexmap", "smallvec 1.15.2", + "taxon", "tendril", ] [[package]] name = "facet-default" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa9e2bfb168e7c767d8808435fcb358d0d2e244cd75f5da09e850afd1e4fff7" +checksum = "98884f7816ad7ac64e8db286a1ec7a3a154805177f9dfd21f60b9b8a28c9309b" dependencies = [ "facet", ] [[package]] name = "facet-dessert" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39aaece2ca1e5aad3e2bee0f4fea23ae0a11eb5a0b88bb265ec09c39d580aac5" +checksum = "6ccdcd3b144d86fa7fc1780d344bb3581e9d050bf731cd4fbbfa155349993488" dependencies = [ "facet-core", "facet-reflect", @@ -3273,9 +3274,9 @@ dependencies = [ [[package]] name = "facet-dom" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd84b5e49e5863eb584244f1582c6cdbc8cd474b79e881ff2fcf0a15a80c452" +checksum = "696c50ca00ab9fd5b15fd86e5f884d6dd260e35ad0df6bed1f77a2d889c4aa2b" dependencies = [ "facet-core", "facet-dessert", @@ -3287,18 +3288,18 @@ dependencies = [ [[package]] name = "facet-error" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175f8aab2cbd354a829214f7f033c5dff59c60b66848fc833116a4dabcc7f7c6" +checksum = "a134040058e341c2b1e681bea278922d0ff3c5572c88dbc565111e7dc626e238" dependencies = [ "facet", ] [[package]] name = "facet-format" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c612924dc3d45853f81e9cfafc513f3ec0be5982f943a7945f49f2492effb1" +checksum = "3c75a4fe14aef3c11614dc018e0c33ed3ac93bc7a8e6c006e0ffa0f7eb715685" dependencies = [ "facet-core", "facet-dessert", @@ -3320,22 +3321,21 @@ dependencies = [ [[package]] name = "facet-json" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82902c758e29af6599f852dd783206697fd33383fa499b07bdcddbd8eb1ef4d0" +checksum = "91b8be2433eba7a2df5cb744714cb62d9ecee4db09722436d6acf68a921723cf" dependencies = [ "facet", "facet-core", "facet-format", "facet-reflect", - "weavy", ] [[package]] name = "facet-macro-parse" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a43cd88c3df76b7606194a059973aea078940de8cc442dca7c327f51bbc014" +checksum = "9bc6dce04d6069e36e8dcdf1699f2650b566eb6dbdfc479342be8ccf2758a4c3" dependencies = [ "facet-macro-types", "proc-macro2", @@ -3344,9 +3344,9 @@ dependencies = [ [[package]] name = "facet-macro-types" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dce57ed093067e4a3a1ab42c8c84eec1a25af7726d43a22e390734b29b947ec" +checksum = "863542bfe7eae71ef32d8a4ba6490d9cfa282545ba56afc82d4ed94fba91a9ca" dependencies = [ "proc-macro2", "quote", @@ -3355,18 +3355,18 @@ dependencies = [ [[package]] name = "facet-macros" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae8b8eb7169a41245f31bda2912a3c7d33e0efa7269c0c8847b54377e31ff68" +checksum = "269a10f70d4ce2a6c26c54238627f1878e78668d695198dc3206279271d68595" dependencies = [ "facet-macros-impl", ] [[package]] name = "facet-macros-impl" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d7d66574a47f07e87058ce6e429a7be58fd77c7686e572cb1f85d8a6a840c6" +checksum = "48789395fedf755c16222693bf6b77870e36b71625f38793c8a06bb3f86845ef" dependencies = [ "facet-macro-parse", "facet-macro-types", @@ -3378,18 +3378,18 @@ dependencies = [ [[package]] name = "facet-path" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a21c558bfb27bf957ead130a3b7e5ea2dee7ccbfb31a524053f061efa164d6f" +checksum = "f6f9587a5b882492c2c250c1f0020fc2f238d26e33bc8a90f953537c4a6eb95d" dependencies = [ "facet-core", ] [[package]] name = "facet-postcard" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8128786bb21103302ddb23f8aa532724d28111982c201a4951700ef8bbd807" +checksum = "ad2c4fc880a32955708f97c544684afcc2ca18218513309e08da49ea4f168c39" dependencies = [ "facet-core", "facet-format", @@ -3400,9 +3400,9 @@ dependencies = [ [[package]] name = "facet-pretty" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dbcf41fe256543a1cdc8a75b3ef130100fbe4a7e180b20472d5601e03349d5b" +checksum = "87e9fdb0a7bd353b20035c1fadf237d2778e06d19fd2537d5a71c8a8919f8aa0" dependencies = [ "facet-core", "facet-reflect", @@ -3412,9 +3412,9 @@ dependencies = [ [[package]] name = "facet-reflect" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8486d2a82dcb2a1bdca472c2cdc513d0feacda3af2ffd50fb9e04f8dedda8735" +checksum = "7a94d748ff98faa8b281580dd759af7b10cc5914603af776fcb58bca9f6d72d7" dependencies = [ "facet-core", "facet-path", @@ -3424,15 +3424,15 @@ dependencies = [ [[package]] name = "facet-singularize" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad2f8eb61bc1f8e7d1b2e3f31ce3d0ac07b9c6bb7479c4d5ba490b2deeffce9" +checksum = "ec34b284a001b4b8125a9c8377964e701a2bb69b05e18e42d1f3cb2716efeeaa" [[package]] name = "facet-solver" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c800eb08ef5e1f18ffafcddf40da5d9ea3c86b8f0486f6ee077b0a3d497812" +checksum = "dbf7661c49144bf0b2f87a9a2088dce873838034734fa749da41d461ce9bad9b" dependencies = [ "facet-core", "facet-reflect", @@ -3460,9 +3460,9 @@ dependencies = [ [[package]] name = "facet-svg" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e97325017feea9dd9679bd66c4cd9fe475eab1b425f000b09d07d86ec3ac05" +checksum = "7cd81ba08b177a9227471e62847a9ac058226a5a50d37597bd294050aba3a11f" dependencies = [ "facet", "facet-xml", @@ -3493,9 +3493,9 @@ dependencies = [ [[package]] name = "facet-toml" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ec563b4a7628e54a1d22b7e3750b2da21721e938f5caf12e63e8347ee33bf92" +checksum = "ebfe86659e1a96f72ff123f037ebc5e6dadb09109fac14ab81d0a68d8e319996" dependencies = [ "facet-core", "facet-format", @@ -3505,9 +3505,9 @@ dependencies = [ [[package]] name = "facet-value" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc5a08618f541594700de7dc032054e04368c06029682d61ab43683d18f5232" +checksum = "408ab3eed05a33bf14d75ec0419e883eb67f6aabad7a79be9cb6d1513ff42b65" dependencies = [ "facet-core", "facet-format", @@ -3517,9 +3517,9 @@ dependencies = [ [[package]] name = "facet-xml" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ffe45cbe59194f92392f45450b2c6925dd2cdbcdbd8da32315b43f1e7f2b6b" +checksum = "71ef04c56f505ccf7049677faeb95f3d6b34be3ee74497b6f35ec8b5785d3c0f" dependencies = [ "facet", "facet-core", @@ -3530,9 +3530,9 @@ dependencies = [ [[package]] name = "facet-yaml" -version = "0.50.0-rc.5" +version = "0.50.0-rc.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01124fed8eb53318eba11476e2a47335f5a5581d7df23baa3db78541bd0cfcd4" +checksum = "adcfb72818c7cd7f71f76cf7a03353bb36bd81cbb9c3bcdc2be99c93e8661365" dependencies = [ "facet", "facet-core", @@ -3580,9 +3580,9 @@ dependencies = [ [[package]] name = "figue" -version = "5.0.0-rc.5" +version = "5.0.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d683110c41f61f23e580c2f11887cfd2667a75388dbbc551b0fec025d3f6aaa8" +checksum = "aee1188934c6b32dfa1aea490c9bcb1acdcc914db2cabc42d1cb0fcfdb8e8064" dependencies = [ "ariadne 0.6.0", "camino", @@ -3606,9 +3606,9 @@ dependencies = [ [[package]] name = "figue-attrs" -version = "5.0.0-rc.5" +version = "5.0.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb26d9b503638fe61b04ef7d76d0d53f39993549948c0267e05094185b6b808" +checksum = "8abaae62355b1223346bb0df83239e1697a42d238b8d2f2fa261414e25708f0e" dependencies = [ "facet", ] @@ -4781,7 +4781,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5521,7 +5521,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7406,7 +7406,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7463,7 +7463,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7917,7 +7917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8283,6 +8283,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" +[[package]] +name = "taxon" +version = "0.2.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acccfbd01e7cf4c7ea384b46df6ba3afb934ad88abd247f92998249854de968" +dependencies = [ + "blake3", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -8293,7 +8302,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8344,7 +8353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9733,7 +9742,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10135,16 +10144,10 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" name = "xtask" version = "0.0.0" dependencies = [ - "camino", "dodeca-protocol", - "eyre", "facet", - "facet-yaml", "figue", - "fs-err", - "indexmap", "owo-colors", - "structstruck", "vox-codegen", ] diff --git a/Cargo.toml b/Cargo.toml index 2d92cc964..65237f6eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,17 +79,17 @@ hotmeal-wasm = { path = "libs/hotmeal/hotmeal-wasm" } cstree = { version = "0.14.0", features = ["derive"] } divan = { package = "codspeed-divan-compat", version = "4.7.0" } -facet = { version = "0.50.0-rc.5", features = ["camino"] } -facet-core = "0.50.0-rc.5" +facet = { version = "0.50.0-rc.7", features = ["camino"] } +facet-core = "0.50.0-rc.7" facet-hash = { version = "0.50.0-rc.5", features = ["jit"] } -facet-reflect = "0.50.0-rc.5" -facet-default = "0.50.0-rc.5" -facet-format = "0.50.0-rc.5" -facet-json = "0.50.0-rc.5" -facet-postcard = "0.50.0-rc.5" -facet-toml = "0.50.0-rc.5" -facet-value = "0.50.0-rc.5" -facet-yaml = "0.50.0-rc.5" +facet-reflect = "0.50.0-rc.7" +facet-default = "0.50.0-rc.7" +facet-format = "0.50.0-rc.7" +facet-json = "0.50.0-rc.7" +facet-postcard = "0.50.0-rc.7" +facet-toml = "0.50.0-rc.7" +facet-value = "0.50.0-rc.7" +facet-yaml = "0.50.0-rc.7" gingembre = { path = "libs/gingembre", version = "0.0.1" } gingembre-syntax = { path = "libs/gingembre-syntax", version = "0.0.1" } diff --git a/DEVELOP.md b/DEVELOP.md index d894ca843..2541ff863 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -29,23 +29,24 @@ Building the browser assets requires: - `pnpm` — installed automatically via `npm install -g pnpm` when missing - the `wasm32-unknown-unknown` Rust target (added by `build-browser-assets.sh`) -## CI Workflow Generation +## CI Workflow -The release workflow and installer script are generated from Rust code, not hand-written YAML. +CI is a hand-written, minimal GitHub Actions workflow: `.github/workflows/ci.yml` +runs `cargo check` and `cargo clippy` on Blacksmith 2-core Linux runners for +pushes/pull requests/merge-group on `main`. Full and private pipelines run on +Buildkite/own infrastructure. -```sh -# Regenerate .github/workflows/release.yml and install.sh -cargo xtask ci +The installer scripts (`install.sh` / `install.ps1`) are generated from Rust code +in `xtask/src/installer.rs`, not hand-written: -# Check if generated files are up to date (used in CI) -cargo xtask ci --check +```sh +# Regenerate the installers +cargo xtask generate-installer install.sh +cargo xtask generate-ps1-installer install.ps1 ``` -The source of truth is `xtask/src/ci.rs`. Edit that file to change: -- Build targets -- Processor crates -- Workflow steps -- Installer script +The source of truth for the installer scripts is `xtask/src/installer.rs` +(`RELEASE_BASE_URL`). Edit that file to change the installer generation. ## Release Process diff --git a/README.md.in b/README.md.in deleted file mode 100644 index 6c7ceb407..000000000 --- a/README.md.in +++ /dev/null @@ -1,91 +0,0 @@ -A fully incremental static site generator. - -## Philosophy - -**Dev mode = Production mode.** Unlike other static site generators that take shortcuts -in development (skipping minification, using original asset paths, etc.), dodeca serves -exactly what you'll get in production: cache-busted URLs, minified HTML, subsetted fonts, -responsive images—the works. Even font subsetting runs in dev! - -This is possible because dodeca uses picante for incremental computation. Every -transformation is a cached query. Change a file and only the affected queries re-run. -First page load builds what's needed; subsequent requests are instant. - -**Custom template engine.** Dodeca includes its own Jinja-like template engine. This gives -you the power of a real language (conditionals, loops, variable interpolation) without -the complexity of learning a new syntax. The engine includes rich diagnostics with -line/column information for template errors. - -**Plugin architecture.** Dodeca's plugin system (via the `plugcard` crate) allows extending -the build pipeline without modifying the core. Image optimization, CSS processing, syntax -highlighting, and more can be plugged in independently. - -## Getting Started - -Install dodeca: - -The recommended way to install dodeca is the install script, which fetches prebuilt binaries from object storage. Releases ship the `ddc` binary plus a set of helper binaries (`ddc-cell-*`) used for things like image processing, Sass, search indexing, etc. - -```bash -curl --proto '=https' --tlsv1.2 -LsSf https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/install.sh | sh -``` - -Supported by the installer: -- macOS (Apple Silicon / `arm64`) -- Linux (`x86_64`) - -After that, you can create a new site: - -```bash -ddc init my-site -cd my-site -ddc serve -``` - -This starts a development server at `http://localhost:8080` with live reload. - -## Key Features - -- **Incremental builds**: Only affected files are re-processed on changes -- **Cache-busted URLs**: Assets get unique names based on content hash -- **Image optimization**: Built-in image processing with responsive variants -- **Font subsetting**: Automatically subset fonts to used characters -- **HTML minification**: Minify HTML in production builds -- **Template engine**: Powerful, easy-to-learn Jinja-like templates -- **Code execution**: Execute code samples and capture output -- **Link checking**: Verify all links in your site -- **Extensible**: Plugin system for custom transformations - -## Structure - -The dodeca workspace includes: - -- **dodeca**: Main SSG binary (`ddc` CLI) -- **gingembre**: Template engine with rich diagnostics -- **plugcard**: Plugin system for extending the build pipeline -- **dodeca-\***: Individual plugins for various transformations -- **livereload-client**: WASM client for live reload in development - -## Development - -Build everything: - -```bash -cargo xtask build -``` - -Run tests: - -```bash -cargo test --workspace -``` - -Build the documentation: - -```bash -cargo doc --workspace --no-deps --open -``` - -## Contributing - -Contributions are welcome! Please open issues and pull requests on GitHub. diff --git a/RELEASING.md b/RELEASING.md index 0d108c410..24c925336 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -86,6 +86,7 @@ website endpoint or retire the tap. - `website.yml` (GitHub Pages docs deploy) is unrelated to releases — it just installs the published binary via the curl installer and builds the docs site. -- The installer download source lives in `xtask/src/ci.rs` +- The installer download source lives in `xtask/src/installer.rs` (`RELEASE_BASE_URL`); `install.sh` / `install.ps1` are generated from it - (`cargo xtask ci-github`, `cargo xtask generate-ps1-installer install.ps1`). + (`cargo xtask generate-installer install.sh`, + `cargo xtask generate-ps1-installer install.ps1`). diff --git a/crates/dodeca-authoring-lsp/src/authoring_lsp.rs b/crates/dodeca-authoring-lsp/src/authoring_lsp.rs index 168ace6b9..07d6d986b 100644 --- a/crates/dodeca-authoring-lsp/src/authoring_lsp.rs +++ b/crates/dodeca-authoring-lsp/src/authoring_lsp.rs @@ -2494,14 +2494,12 @@ pub fn diagnostic_for_reference( format!("source file '{source_target}' not found"), )); }; - match missing_anchor_message(project, route, fragment) { - Some(message) => ( - AuthoringDiagnosticKind::Anchor, - Some(route.clone()), - message, - ), - None => return None, - } + let message = missing_anchor_message(project, route, fragment)?; + ( + AuthoringDiagnosticKind::Anchor, + Some(route.clone()), + message, + ) } else if reference.kind == MarkdownReferenceKind::Image || is_likely_static_file(target_without_fragment) { @@ -2522,10 +2520,9 @@ pub fn diagnostic_for_reference( Some(target_route.clone()), format!("route '{target_route}' not found"), ) - } else if let Some(message) = missing_anchor_message(project, &target_route, fragment) { - (AuthoringDiagnosticKind::Anchor, Some(target_route), message) } else { - return None; + let message = missing_anchor_message(project, &target_route, fragment)?; + (AuthoringDiagnosticKind::Anchor, Some(target_route), message) } }; diff --git a/crates/dodeca-config/README.md.in b/crates/dodeca-config/README.md.in deleted file mode 100644 index 3469df25d..000000000 --- a/crates/dodeca-config/README.md.in +++ /dev/null @@ -1,7 +0,0 @@ -Configuration types for the dodeca static site generator. - -This crate provides the shared config structures consumed by the main `dodeca` binary -and related tooling. It defines the schema and serialization formats used to parse -project configuration files. - -Most users should interact with these types through the `dodeca` CLI. \ No newline at end of file diff --git a/crates/dodeca-debug/README.md.in b/crates/dodeca-debug/README.md.in deleted file mode 100644 index 5fca5f050..000000000 --- a/crates/dodeca-debug/README.md.in +++ /dev/null @@ -1,12 +0,0 @@ -## dodeca-debug - -Low-level debug utilities used across the dodeca workspace. - -This crate hosts small, platform-focused helpers (for example, OS-specific -introspection or debugging primitives) that don’t belong in higher-level -crates. - -### Status - -Internal crate used by the workspace. Not intended for direct consumption. - diff --git a/crates/dodeca-devtools/README.md.in b/crates/dodeca-devtools/README.md.in deleted file mode 100644 index 68d8f407b..000000000 --- a/crates/dodeca-devtools/README.md.in +++ /dev/null @@ -1,7 +0,0 @@ -`dodeca-devtools` is the WebAssembly browser runtime for dodeca DevTools. - -It handles live DOM patching, CSS reload, route subscriptions, dead-link -actions, and open-in-editor actions during `ddc serve`. The visible DevTools UI -is the TypeScript bundle under `crates/dodeca/devtools-ui`. - -This crate is part of the Dodeca workspace. diff --git a/crates/dodeca-protocol/README.md.in b/crates/dodeca-protocol/README.md.in deleted file mode 100644 index ff41eb873..000000000 --- a/crates/dodeca-protocol/README.md.in +++ /dev/null @@ -1,17 +0,0 @@ -## dodeca-protocol - -Shared protocol types for dodeca devtools. This crate contains the data structures -and serialization formats used by the devtools runtime and the live reload client. - -### What this crate is for - -- Defining stable protocol types shared across dodeca components -- Encoding and decoding messages for devtools communication -- Keeping compatibility boundaries explicit and versioned - -### When to use it - -Use `dodeca-protocol` when you need to serialize or interpret messages exchanged -between dodeca devtools components (e.g. host <-> client). - -This crate is internal to the dodeca workspace and is not intended for standalone use. \ No newline at end of file diff --git a/crates/dodeca-sandbox/README.md.in b/crates/dodeca-sandbox/README.md.in deleted file mode 100644 index 233b996de..000000000 --- a/crates/dodeca-sandbox/README.md.in +++ /dev/null @@ -1,15 +0,0 @@ -Cross-platform process sandboxing for dodeca. - -This crate provides the sandboxing layer used by dodeca to run untrusted or isolated processes -with platform-specific backends. - -## Features - -- Cross-platform API with OS-specific implementations. -- Linux support via `hakoniwa`. -- Focused, minimal surface area for secure process execution. - -## Status - -This crate is internal to the dodeca workspace and is not published. - diff --git a/crates/dodeca/README.md.in b/crates/dodeca/README.md.in deleted file mode 100644 index 67bccc9c0..000000000 --- a/crates/dodeca/README.md.in +++ /dev/null @@ -1,29 +0,0 @@ -Dodeca is a fully incremental static site generator. - -## Overview - -This crate provides the `ddc` CLI and the core runtime that powers Dodeca’s build -pipeline, including cells, hot-reload, and devtools integration. - -## Features - -- Incremental builds with fine‑grained caching -- Cell-based pipeline for content processing -- Live reload and devtools support -- Cross-platform and workspace-friendly - -## Usage - -Install from the workspace: - -- `cargo install --path crates/dodeca` - -Run on a site directory: - -- `ddc serve /path/to/site` -- `ddc build /path/to/site` - -## Workspace - -This crate is part of the `dodeca` workspace. Most users should interact with -the `ddc` CLI rather than depending on this crate directly. \ No newline at end of file diff --git a/crates/dodeca/src/config.rs b/crates/dodeca/src/config.rs index 1ca556150..6c01ffd72 100644 --- a/crates/dodeca/src/config.rs +++ b/crates/dodeca/src/config.rs @@ -709,10 +709,7 @@ fn discover_source_config( }); return source.map(|s| (current.to_owned(), s)); } - match current.parent() { - Some(parent) => current = parent, - None => return None, - } + current = current.parent()?; } } diff --git a/crates/dodeca/src/error_pages.rs b/crates/dodeca/src/error_pages.rs index d93915657..0f06bb334 100644 --- a/crates/dodeca/src/error_pages.rs +++ b/crates/dodeca/src/error_pages.rs @@ -428,7 +428,7 @@ fn format_source_context( if loc.filename.starts_with('/') { // Absolute path - make it a zed:// link // Note: zed://file/ expects path without leading slash - let zed_url = format!("zed://file{}:{}:{}", &loc.filename, error_line, error_col); + let zed_url = format!("zed://file{}:{}:{}", loc.filename, error_line, error_col); html.push_str(&format!( r#"{}"#, html_escape::encode_text(&zed_url), diff --git a/crates/dodeca/src/theme_resolver.rs b/crates/dodeca/src/theme_resolver.rs index 8f5f7083f..05b1ad5a9 100644 --- a/crates/dodeca/src/theme_resolver.rs +++ b/crates/dodeca/src/theme_resolver.rs @@ -184,9 +184,9 @@ mod tests { // Print sample to verify format (only in --nocapture mode) println!("\n=== Light theme CSS sample (first 200 chars) ==="); - println!("{}", &light_css.chars().take(200).collect::()); + println!("{}", light_css.chars().take(200).collect::()); println!("\n=== Dark theme CSS sample (first 200 chars) ==="); - println!("{}", &dark_css.chars().take(200).collect::()); + println!("{}", dark_css.chars().take(200).collect::()); } #[test] diff --git a/crates/integration-tests/README.md.in b/crates/integration-tests/README.md.in deleted file mode 100644 index 50520e980..000000000 --- a/crates/integration-tests/README.md.in +++ /dev/null @@ -1,15 +0,0 @@ -## Integration tests - -This crate contains the end-to-end and integration test harness for the dodeca workspace. -It’s a binary crate used during development and CI to validate the full system. - -### What it includes - -- CLI-driven integration tests -- Fixture setup and teardown -- Network and process orchestration helpers - -### When to use it - -Run this crate when you want to verify cross-crate behavior or ensure the full stack works -as expected before publishing or deploying. diff --git a/crates/livereload-client/README.md.in b/crates/livereload-client/README.md.in deleted file mode 100644 index 460ab7cf0..000000000 --- a/crates/livereload-client/README.md.in +++ /dev/null @@ -1,14 +0,0 @@ -# livereload-client - -WASM client for dodeca live reload. It connects to the devtools server, receives DOM patch messages, and applies them in the browser to update pages without a full reload. - -## What it does - -- Opens a websocket connection to dodeca devtools -- Receives patch messages encoded with `dodeca-protocol` -- Applies DOM patches using `hotmeal-wasm` - -## Usage - -This crate is built and bundled by the workspace tooling; you typically don’t depend on it directly. - diff --git a/hooks/pre-commit b/hooks/pre-commit index 89eaa9ac9..cd6b77550 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -2,9 +2,3 @@ set -euo pipefail captain - -# Regenerate CI workflow files and stage them. -# This ensures ci.yml always reflects the current set of cells/crates. -cargo xtask ci-github -cargo xtask ci-forgejo -git add .github/workflows/ci.yml .forgejo/workflows/ci.yml install.sh diff --git a/libs/gingembre-syntax/src/ast.rs b/libs/gingembre-syntax/src/ast.rs index 31599298d..441b34bc7 100644 --- a/libs/gingembre-syntax/src/ast.rs +++ b/libs/gingembre-syntax/src/ast.rs @@ -410,8 +410,10 @@ impl DictLit { .filter_map(|c| Expr::cast(c.clone())) .collect(); exprs - .chunks_exact(2) - .map(|c| (c[0].clone(), c[1].clone())) + .as_chunks::<2>() + .0 + .iter() + .map(|[key, value]| (key.clone(), value.clone())) .collect() } } diff --git a/libs/gingembre/README.md.in b/libs/gingembre/README.md.in deleted file mode 100644 index 4305e6a70..000000000 --- a/libs/gingembre/README.md.in +++ /dev/null @@ -1,6 +0,0 @@ -`gingembre` is a Jinja-like template engine with rich error diagnostics. - -It supports template inheritance, macros, filters, and lazy data resolution, -and is used by dodeca for all HTML templating. - -This crate is part of the Dodeca workspace. diff --git a/libs/hotmeal/Cargo.toml b/libs/hotmeal/Cargo.toml index 27955bbd0..9b1667eb7 100644 --- a/libs/hotmeal/Cargo.toml +++ b/libs/hotmeal/Cargo.toml @@ -13,10 +13,10 @@ homepage = "https://github.com/bearcove/hotmeal" [workspace.dependencies] # Facet dependencies -facet = "0.50.0-rc.5" -facet-error = "0.50.0-rc.5" -facet-json = "0.50.0-rc.5" -facet-testhelpers = "0.50.0-rc.5" +facet = "0.50.0-rc.7" +facet-error = "0.50.0-rc.7" +facet-json = "0.50.0-rc.7" +facet-testhelpers = "0.50.0-rc.7" # Local crates cinereus = { path = "cinereus", version = "3.0.0-rc.0" } @@ -24,7 +24,7 @@ hotmeal = { path = "hotmeal", version = "3.0.0-rc.0" } hotmeal-server = { path = "hotmeal-server", version = "3.0.0-rc.0" } # Serialization -facet-postcard = "0.50.0-rc.5" +facet-postcard = "0.50.0-rc.7" # RPC vox = { version = "0.10.0-rc.5", default-features = false, features = ["runtime"] } diff --git a/libs/hotmeal/README.md.in b/libs/hotmeal/README.md.in deleted file mode 100644 index 278f320bc..000000000 --- a/libs/hotmeal/README.md.in +++ /dev/null @@ -1,5 +0,0 @@ -hotmeal is an HTML toolkit based on facet, html5ever, and cinereus (for diff-patching). - -It allows patching HTML documents into a DOM the way browsers would do, manipulating the DOM, -re-serializing the DOM, but also generating patches you can apply in an actual browser, for -live-reloading. diff --git a/libs/hotmeal/cinereus/README.md.in b/libs/hotmeal/cinereus/README.md.in deleted file mode 100644 index d5b763eae..000000000 --- a/libs/hotmeal/cinereus/README.md.in +++ /dev/null @@ -1,9 +0,0 @@ -GumTree-style tree diffing with Chawathe edit script generation. - -This crate provides a generic tree diffing algorithm based on the GumTree approach: - -1. **Top-down matching**: Finds identical subtrees by hash -2. **Bottom-up matching**: Matches remaining nodes by structural similarity -3. **Chawathe edit script**: Generates minimal INSERT, DELETE, UPDATE, and MOVE operations - -Used by [hotmeal](https://github.com/bearcove/hotmeal) for HTML diffing. diff --git a/libs/hotmeal/hotmeal-wasm/README.md.in b/libs/hotmeal/hotmeal-wasm/README.md.in deleted file mode 100644 index 68fdc889a..000000000 --- a/libs/hotmeal/hotmeal-wasm/README.md.in +++ /dev/null @@ -1,26 +0,0 @@ -# hotmeal-wasm - -WebAssembly bindings for hotmeal HTML toolkit. - -Provides browser-side HTML parsing, DOM diffing, and patch application for use in JavaScript/TypeScript applications. - -## Features - -- Parse HTML strings into DOM trees -- Compute minimal patches between two HTML documents -- Apply patches to update the DOM efficiently -- Works with any JavaScript framework or vanilla JS - -## Usage - -```javascript -import init, { parse_html, diff_html, apply_patches } from 'hotmeal-wasm'; - -await init(); - -const old_html = '

Hello

'; -const new_html = '

World

'; - -const patches = diff_html(old_html, new_html); -// Apply patches to your DOM... -``` diff --git a/libs/hotmeal/hotmeal/README.md.in b/libs/hotmeal/hotmeal/README.md.in deleted file mode 100644 index 218a891b5..000000000 --- a/libs/hotmeal/hotmeal/README.md.in +++ /dev/null @@ -1,27 +0,0 @@ -# hotmeal - -HTML toolkit with arena-based DOM, html5ever parsing, tree diffing, and serialization. - -## Features - -- **HTML5 Parsing**: Full HTML5 tree construction via html5ever with error recovery -- **Arena-based DOM**: Efficient arena-allocated tree with zero-copy parsing -- **Tree Diffing**: GumTree/Chawathe algorithm via cinereus for efficient DOM patches -- **Serialization**: HTML output with proper escaping and formatting - -## Usage - -```rust -use hotmeal::{parse_body, diff::{diff, apply_patches}}; - -// Parse HTML -let old = parse_body("

Hello

"); -let new = parse_body("

World

"); - -// Compute patches -let patches = hotmeal::diff::diff(&old, &new).expect("diffing should succeed"); - -// Apply patches to transform old into new -let mut old_diff: hotmeal::diff::Element = (&old).into(); -hotmeal::diff::apply_patches(&mut old_diff, &patches).expect("patches should apply"); -``` diff --git a/libs/hotmeal/hotmeal/fuzz/browser-proto/Cargo.toml b/libs/hotmeal/hotmeal/fuzz/browser-proto/Cargo.toml index 2981fdaef..2f514d179 100644 --- a/libs/hotmeal/hotmeal/fuzz/browser-proto/Cargo.toml +++ b/libs/hotmeal/hotmeal/fuzz/browser-proto/Cargo.toml @@ -6,6 +6,6 @@ edition = "2024" [dependencies] vox = { version = "0.10.0-rc.5", default-features = false, features = ["runtime"] } -facet = "0.50.0-rc.5" -facet-postcard = "0.50.0-rc.5" +facet = "0.50.0-rc.7" +facet-postcard = "0.50.0-rc.7" hotmeal = { path = "../.." } diff --git a/libs/marq/README.md.in b/libs/marq/README.md.in deleted file mode 100644 index fc8848681..000000000 --- a/libs/marq/README.md.in +++ /dev/null @@ -1,14 +0,0 @@ -# marq - -Markdown processing (based on pulldown-cmark), recognizes -[tracey](https://github.com/bearcove/tracey) rules, uses -[arborium](https://github.com/bearcove/arborium) for syntax highlighting, -[aasvg-rs](https://github.com/bearcove/aasvg-rs) and -[pikru](https://github.com/bearcove/pikru) for diagrams. - -Supports wiki-style links (`[[Target]]`, `[[Target|label]]`) through a -`WikiLinkResolver`, so applications can resolve them against their own page -index while marq handles the Markdown syntax. - -Used by [dodeca](https://github.com/bearcove/dodeca) and -[tracey](https://github.com/bearcove/tracey) diff --git a/libs/marq/src/render.rs b/libs/marq/src/render.rs index 8702309cf..3280001d8 100644 --- a/libs/marq/src/render.rs +++ b/libs/marq/src/render.rs @@ -786,7 +786,7 @@ fn parse_emphasis_shortcode(input: &str) -> (String, Vec<(String, String)>) { let args = rest.strip_suffix(')').unwrap_or(rest); let mut pairs = Vec::new(); - let mut chars = args.char_indices().peekable(); + let chars = args.char_indices().peekable(); // Split into `key = value` items, honoring quotes so a quoted value can hold commas. let mut item = String::new(); let mut in_quotes = false; @@ -806,7 +806,7 @@ fn parse_emphasis_shortcode(input: &str) -> (String, Vec<(String, String)>) { pairs.push((item.to_string(), String::new())); } }; - while let Some((_, c)) = chars.next() { + for (_, c) in chars { match c { '"' => { in_quotes = !in_quotes; @@ -831,9 +831,11 @@ fn parse_emphasis_shortcode(input: &str) -> (String, Vec<(String, String)>) { /// (with the leading `:` removed) and the remaining blockquote events as a body to be /// rendered — the marker paragraph itself is dropped. `events` is the full blockquote /// run, `[Start(BlockQuote), … , End(BlockQuote)]`. -fn extract_body_shortcode<'a>( - events: &[(Event<'a>, Range)], -) -> Option<(String, Vec<(Event<'a>, Range)>)> { +/// A body shortcode match: the marker text (leading `:` removed) and the +/// remaining blockquote events as a body to be rendered. +type BodyShortcode<'a> = (String, Vec<(Event<'a>, Range)>); + +fn extract_body_shortcode<'a>(events: &[(Event<'a>, Range)]) -> Option> { // events[0] is Start(BlockQuote); the first paragraph must open with an emphasis. if !matches!(events.first(), Some((Event::Start(Tag::BlockQuote(_)), _))) { return None; diff --git a/libs/pikru/Cargo.toml b/libs/pikru/Cargo.toml index a70adf947..f10bac568 100644 --- a/libs/pikru/Cargo.toml +++ b/libs/pikru/Cargo.toml @@ -36,7 +36,7 @@ ariadne = "0.6" pest = "2.8.4" pest_derive = "2.8.4" # SVG DOM generation and XML serialization -facet-svg = { version = "0.50.0-rc.5" } +facet-svg = { version = "0.50.0-rc.7" } # Debug logging (only emits when RUST_LOG is set) - optional tracing = { version = "0.1.43", optional = true } # Derive macro for custom error types diff --git a/libs/pikru/README.md.in b/libs/pikru/README.md.in deleted file mode 100644 index eca19b722..000000000 --- a/libs/pikru/README.md.in +++ /dev/null @@ -1,58 +0,0 @@ -A Rust implementation of [pikchr](https://pikchr.org/), a PIC-like diagram -markup language for creating technical diagrams that generates SVG. - -## Usage - -```rust -let svg = pikru::pikchr(r#"box "Hello" arrow box "World""#).unwrap(); -assert!(svg.contains("` block with CSS variables using -`light-dark()`, so colors automatically adapt to the user's color scheme. - -## Development - -### Testing - -The test suite validates output against the original C implementation: - -```bash -cargo test # Run all tests -cargo xtask compare-html # Generate visual comparison HTML -cargo xtask generate-pngs # Convert SVGs to PNGs for debugging -``` - -See `comparison.html` for a side-by-side visual comparison of C vs Rust output. - -### Pre-commit Hooks - -```bash -./hooks/install.sh -``` - -## Attribution - -pikru is a direct Rust port of: - -- **[pikchr](https://pikchr.org/)** by D. Richard Hipp - The original C - implementation, released under the - [0-clause BSD license](https://pikchr.org/home/doc/trunk/doc/license.md). - The C source is vendored in `vendor/pikchr-c/` for reference. - -Also referenced during development: - -- **[gopikchr](https://github.com/gopikchr/gopikchr)** - A Go port of pikchr - by [@zellyn](https://github.com/zellyn). diff --git a/libs/pikru/crates/pikru-compare/Cargo.toml b/libs/pikru/crates/pikru-compare/Cargo.toml index 4660dbfed..696a3d745 100644 --- a/libs/pikru/crates/pikru-compare/Cargo.toml +++ b/libs/pikru/crates/pikru-compare/Cargo.toml @@ -10,8 +10,8 @@ publish = false rustdoc-args = ["--html-in-header", "arborium-header.html"] [dependencies] -facet-svg = { version = "0.50.0-rc.5" } -rediff = { version = "0.50.0-rc.0" } +facet-svg = { version = "0.50.0-rc.7" } +rediff = { version = "0.50.0-rc.7" } camino = "1.2.1" # Visual comparison diff --git a/libs/pikru/crates/pikru-mcp/Cargo.toml b/libs/pikru/crates/pikru-mcp/Cargo.toml index a155a4722..872ef2020 100644 --- a/libs/pikru/crates/pikru-mcp/Cargo.toml +++ b/libs/pikru/crates/pikru-mcp/Cargo.toml @@ -22,8 +22,8 @@ anyhow = "1" # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -facet = "0.50.0-rc.5" -facet-json = "0.50.0-rc.5" +facet = "0.50.0-rc.7" +facet-json = "0.50.0-rc.7" # Image processing image = "0.25" diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 8ecdc4f9c..d2b77844a 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -1,6 +1,6 @@ #!/bin/bash # Publish dodeca release artifacts to the Scaleway Object Storage bucket the -# installer reads from (see install.sh / xtask/src/ci.rs RELEASE_BASE_URL). +# installer reads from (see install.sh / xtask/src/installer.rs RELEASE_BASE_URL). # # Usage: scripts/publish-release.sh vX.Y.Z [dist-dir] # Run after scripts/release.sh has populated dist/ (tarballs + installers + diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 12c53877f..1550272b0 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -12,15 +12,9 @@ rustdoc-args = ["--html-in-header", "arborium-header.html"] rustdoc-args = ["--html-in-header", "arborium-header.html"] [dependencies] -camino.workspace = true -facet = { workspace = true, features = ["indexmap"] } +facet.workspace = true figue.workspace = true -facet-yaml.workspace = true -fs-err.workspace = true -indexmap.workspace = true -eyre.workspace = true owo-colors.workspace = true -structstruck.workspace = true # For `xtask codegen --typescript`: the same generator + protocol descriptors that # crates/dodeca/build.rs uses to emit the bundles' *.generated.ts vox bindings. dodeca-protocol = { path = "../crates/dodeca-protocol" } diff --git a/xtask/README.md.in b/xtask/README.md.in deleted file mode 100644 index 79f14877e..000000000 --- a/xtask/README.md.in +++ /dev/null @@ -1,28 +0,0 @@ -Build automation tasks for the dodeca workspace. - -This crate provides custom `cargo xtask` commands to simplify common development workflows: - -## Available Tasks - -### Build - -Build all workspace artifacts including WASM, plugins, and binaries: - -```bash -cargo xtask build -``` - -This: -- Compiles the main `dodeca` binary -- Builds all plugin crates (dodeca-webp, dodeca-sass, etc.) -- Compiles the WASM livereload client -- Generates all necessary artifacts - -### Other Tasks - -Use `cargo xtask --help` to see all available tasks. - -## Notes - -The WASM build uses `wasm-bindgen`, and the CI automatically extracts the matching -CLI version from `Cargo.lock` to ensure consistency. diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs deleted file mode 100644 index b5933632e..000000000 --- a/xtask/src/ci.rs +++ /dev/null @@ -1,2260 +0,0 @@ -//! CI workflow generation for GitHub Actions and Forgejo Actions. -//! -//! This module provides typed representations of GitHub/Forgejo Actions workflow files -//! and generates the release workflow for dodeca. - -#![allow(dead_code)] // Scaffolding for future CI features - -use facet::Facet; -use indexmap::IndexMap; - -// ============================================================================= -// CI Platform Configuration -// ============================================================================= - -/// The CI platform we're generating workflows for. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CiPlatform { - GitHub, - Forgejo, -} - -impl CiPlatform { - /// Get the workflow directory path relative to repo root. - pub fn workflows_dir(&self) -> &'static str { - match self { - CiPlatform::GitHub => ".github/workflows", - CiPlatform::Forgejo => ".forgejo/workflows", - } - } - - /// Get the context variable prefix (e.g., "github"). - /// Note: Forgejo uses "github" for compatibility with GitHub Actions. - pub fn context_prefix(&self) -> &'static str { - // Both platforms use "github" - Forgejo maintains compatibility - "github" - } - - /// Format a context variable reference. - pub fn context_var(&self, var: &str) -> String { - format!("${{{{ {}.{} }}}}", self.context_prefix(), var) - } - - /// Get the checkout action for this platform. - pub fn checkout_action(&self) -> &'static str { - match self { - CiPlatform::GitHub => "actions/checkout@v4", - // Forgejo can use actions from GitHub via full URL or has its own - CiPlatform::Forgejo => "https://github.com/actions/checkout@v4", - } - } - - /// Get the upload-artifact action for this platform. - pub fn upload_artifact_action(&self) -> &'static str { - match self { - CiPlatform::GitHub => "actions/upload-artifact@v4", - CiPlatform::Forgejo => "https://data.forgejo.org/actions/upload-artifact@v3", - } - } - - /// Get the download-artifact action for this platform. - pub fn download_artifact_action(&self) -> &'static str { - match self { - CiPlatform::GitHub => "actions/download-artifact@v4", - CiPlatform::Forgejo => "https://data.forgejo.org/actions/download-artifact@v3", - } - } - - /// Get the rust-toolchain action for this platform. - /// Uses nightly for -Z checksum-freshness support (better caching). - pub fn rust_toolchain_action(&self) -> &'static str { - match self { - // Note: This specifies the action, not the toolchain version. - // The toolchain version is set via the "toolchain" input parameter. - CiPlatform::GitHub => "dtolnay/rust-toolchain@master", - CiPlatform::Forgejo => "https://github.com/dtolnay/rust-toolchain@master", - } - } - - /// The pinned toolchain to use across all CI jobs. - pub const RUST_TOOLCHAIN: &'static str = "stable"; - - /// Get the local cache action for this platform (for self-hosted runners). - pub fn local_cache_action(&self) -> &'static str { - match self { - CiPlatform::GitHub => "bearcove/local-cache@a3ee51e34146df8cdfc7ea67188e9ca4e2364794", - // Forgejo: we use ctree-based local caching (shell scripts), not an action - CiPlatform::Forgejo => "unused", - } - } - - /// Check if this platform uses the local-cache action (with base path) or standard cache. - pub fn uses_local_cache(&self) -> bool { - matches!(self, CiPlatform::GitHub) - } - - /// Check if this platform uses ctree for local caching (shell-based). - pub fn uses_ctree_cache(&self) -> bool { - matches!(self, CiPlatform::Forgejo) - } - - /// Check if this platform uses SSH-based CAS for artifacts. - pub fn uses_ssh_cas_artifacts(&self) -> bool { - matches!(self, CiPlatform::Forgejo) - } - - /// Get the Swatinem rust-cache action for this platform (for non-self-hosted runners). - pub fn rust_cache_action(&self) -> &'static str { - match self { - CiPlatform::GitHub => "Swatinem/rust-cache@v2", - CiPlatform::Forgejo => "https://github.com/Swatinem/rust-cache@v2", - } - } - - /// Get the wasm-pack installer action for this platform. - pub fn wasm_pack_action(&self) -> &'static str { - match self { - CiPlatform::GitHub => "jetli/wasm-pack-action@v0.4.0", - CiPlatform::Forgejo => "https://github.com/jetli/wasm-pack-action@v0.4.0", - } - } -} - -// ============================================================================= -// Configuration -// ============================================================================= - -/// Bearcove-hosted Linux runner for compile-heavy jobs. -const GITHUB_LINUX_RUNNER: &str = "bearcove-ubuntu-24.04"; - -/// Free GitHub-hosted macOS runner for every-commit compile checks. -const GITHUB_MACOS_CHECK_RUNNER: &str = "macos-15"; - -/// Free GitHub-hosted Windows runner for every-commit compile checks. -const GITHUB_WINDOWS_CHECK_RUNNER: &str = "windows-latest"; - -/// Blacksmith macOS runner for tag-only release packaging (bigger = binaries ready sooner). -const GITHUB_MACOS_PACKAGE_RUNNER: &str = "blacksmith-12vcpu-macos-15"; - -/// Blacksmith Windows runner for tag-only release packaging. -const GITHUB_WINDOWS_PACKAGE_RUNNER: &str = "blacksmith-8vcpu-windows-2025"; - -/// Self-hosted runner labels for Forgejo macOS. -const FORGEJO_MACOS_LABELS: &[&str] = &["mac"]; - -/// Self-hosted runner labels for Forgejo Linux. -const FORGEJO_LINUX_LABELS: &[&str] = &["linux-x86_64-trusted"]; - -/// Get target platforms for a specific CI platform. -pub fn targets_for_platform(platform: CiPlatform) -> Vec { - vec![ - Target { - triple: "x86_64-unknown-linux-gnu", - os: "ubuntu-24.04", - runner: linux_runner(platform), - archive_ext: "tar.xz", - }, - Target { - triple: "aarch64-apple-darwin", - os: "macos-15", - runner: macos_runner(platform), - archive_ext: "tar.xz", - }, - ] -} - -/// Target platforms for CI and releases (GitHub default for backwards compatibility). -pub fn default_targets() -> Vec { - targets_for_platform(CiPlatform::GitHub) -} - -/// Get the Linux runner for a CI platform. -fn linux_runner(platform: CiPlatform) -> RunnerSpec { - match platform { - CiPlatform::GitHub => RunnerSpec::single(GITHUB_LINUX_RUNNER), - CiPlatform::Forgejo => RunnerSpec::labels(FORGEJO_LINUX_LABELS), - } -} - -/// Get the macOS runner for a CI platform. -fn macos_runner(platform: CiPlatform) -> RunnerSpec { - match platform { - CiPlatform::GitHub => RunnerSpec::single(GITHUB_MACOS_CHECK_RUNNER), - CiPlatform::Forgejo => RunnerSpec::labels(FORGEJO_MACOS_LABELS), - } -} - -/// Runner specification - can be a single string or a list of labels. -#[derive(Debug, Clone)] -pub enum RunnerSpec { - /// A single runner name (e.g., "ubuntu-latest") - Single(String), - /// Multiple labels for self-hosted runners (e.g., ["self-hosted", "Linux", "X64"]) - Labels(Vec), -} - -impl RunnerSpec { - /// Create a single runner spec from a static string. - pub fn single(s: &str) -> Self { - RunnerSpec::Single(s.to_string()) - } - - /// Create a labels runner spec from a slice of static strings. - pub fn labels(labels: &[&str]) -> Self { - RunnerSpec::Labels(labels.iter().map(|s| s.to_string()).collect()) - } - - /// Convert to the Job's runs_on field. - pub fn to_runs_on(&self) -> RunsOn { - match self { - RunnerSpec::Single(s) => RunsOn::single(s.clone()), - RunnerSpec::Labels(labels) => RunsOn::multiple(labels.iter().cloned()), - } - } - - /// Check if this is a self-hosted runner. - pub fn is_self_hosted(&self) -> bool { - matches!(self, RunnerSpec::Labels(_)) - } -} - -/// A target platform configuration. -pub struct Target { - pub triple: &'static str, - pub os: &'static str, - pub runner: RunnerSpec, - pub archive_ext: &'static str, -} - -impl Target { - /// Get a short name for the target (e.g., "linux-x64"). - pub fn short_name(&self) -> &'static str { - match self.triple { - "x86_64-unknown-linux-gnu" => "linux-x64", - "aarch64-unknown-linux-gnu" => "linux-arm64", - "aarch64-apple-darwin" => "macos-arm64", - "x86_64-pc-windows-msvc" => "windows-x64", - _ => self.triple, - } - } - - /// Check if this target uses a self-hosted runner. - pub fn is_self_hosted(&self) -> bool { - self.runner.is_self_hosted() - } - - /// Get the RunsOn configuration for this target. - pub fn runs_on(&self) -> RunsOn { - self.runner.to_runs_on() - } - - /// Get the local cache base path for self-hosted runners. - pub fn cache_base_path(&self) -> &'static str { - match self.triple { - "aarch64-apple-darwin" => "/Users/amos/.cache", - "x86_64-unknown-linux-gnu" => "/home/amos/.cache", - _ => "/tmp/.cache", - } - } -} - -// ============================================================================= -// GitHub Actions Workflow Schema -// ============================================================================= - -structstruck::strike! { - /// A GitHub Actions workflow file. - #[structstruck::each[derive(Debug, Clone, Facet)]] - #[facet(rename_all = "kebab-case")] - pub struct Workflow { - /// The name of the workflow displayed in the GitHub UI. - pub name: String, - - /// The events that trigger the workflow. - pub on: On, - - /// Permissions for the workflow. - #[facet(default, skip_serializing_if = Option::is_none)] - pub permissions: Option>, - - /// Environment variables available to all jobs. - #[facet(default, skip_serializing_if = Option::is_none)] - pub env: Option>, - - /// The jobs that make up the workflow. - pub jobs: IndexMap, - } -} - -structstruck::strike! { - /// Events that trigger a workflow. - #[structstruck::each[derive(Debug, Clone, Facet)]] - #[facet(rename_all = "snake_case")] - pub struct On { - /// Trigger on push events. - #[facet(default, skip_serializing_if = Option::is_none)] - pub push: Option>, - /// Branches to trigger on. - #[facet(default, skip_serializing_if = Option::is_none)] - pub branches: Option>, - }>, - - /// Trigger on pull request events. - #[facet(default, skip_serializing_if = Option::is_none)] - pub pull_request: Option>, - }>, - - /// Trigger on merge group events (for merge queues). - #[facet(default, skip_serializing_if = Option::is_none)] - pub merge_group: Option>, - }>, - - /// Trigger on workflow dispatch (manual). - #[facet(default, skip_serializing_if = Option::is_none)] - pub workflow_dispatch: Option, - } -} - -/// Runner specification for GitHub Actions. -/// -/// Always serialized as an array of labels, which GitHub Actions accepts for both -/// single runners (e.g., ["ubuntu-latest"]) and multi-label self-hosted runners -/// (e.g., ["self-hosted", "Linux", "X64"]). -#[derive(Debug, Clone, Facet)] -#[facet(transparent)] -pub struct RunsOn(pub Vec); - -impl RunsOn { - /// Create a single runner. - pub fn single(s: impl Into) -> Self { - RunsOn(vec![s.into()]) - } - - /// Create multiple runner labels. - pub fn multiple(labels: impl IntoIterator>) -> Self { - RunsOn(labels.into_iter().map(Into::into).collect()) - } -} - -#[derive(Debug, Clone, Facet)] -#[facet(rename_all = "kebab-case")] -pub struct Container { - pub image: String, - #[facet(default, skip_serializing_if = Option::is_none)] - pub volumes: Option>, - #[facet(default, skip_serializing_if = Option::is_none)] - pub options: Option, -} - -structstruck::strike! { - /// A job in a workflow. - #[structstruck::each[derive(Debug, Clone, Facet)]] - #[facet(rename_all = "kebab-case")] - pub struct Job { - /// Display name for the job in the GitHub UI. - #[facet(default, skip_serializing_if = Option::is_none)] - pub name: Option, - - /// The runner to use. - pub runs_on: RunsOn, - - /// Optional job container. - #[facet(default, skip_serializing_if = Option::is_none)] - pub container: Option, - - /// Maximum time in minutes for the job to run. - #[facet(default, skip_serializing_if = Option::is_none)] - pub timeout_minutes: Option, - - /// Jobs that must complete before this one. - #[facet(default, skip_serializing_if = Option::is_none)] - pub needs: Option>, - - /// Condition for running this job. - #[facet(default, skip_serializing_if = Option::is_none, rename = "if")] - pub if_condition: Option, - - /// Outputs from this job. - #[facet(default, skip_serializing_if = Option::is_none)] - pub outputs: Option>, - - /// Environment variables for this job. - #[facet(default, skip_serializing_if = Option::is_none)] - pub env: Option>, - - /// Allow job failure without failing the workflow. - #[facet(default, skip_serializing_if = Option::is_none, rename = "continue-on-error")] - pub continue_on_error: Option, - - /// Permissions for this job (overrides workflow-level permissions). - #[facet(default, skip_serializing_if = Option::is_none)] - pub permissions: Option>, - - /// The steps to run. - pub steps: Vec, - } -} - -structstruck::strike! { - /// A step in a job. - #[structstruck::each[derive(Debug, Clone, Facet)]] - #[facet(rename_all = "kebab-case")] - pub struct Step { - /// The name of the step. - #[facet(default, skip_serializing_if = Option::is_none)] - pub name: Option, - - /// Step ID for referencing outputs. - #[facet(default, skip_serializing_if = Option::is_none)] - pub id: Option, - - /// Use a GitHub Action. - #[facet(default, skip_serializing_if = Option::is_none)] - pub uses: Option, - - /// Run a shell command. - #[facet(default, skip_serializing_if = Option::is_none)] - pub run: Option, - - /// Shell to use for run commands. - #[facet(default, skip_serializing_if = Option::is_none)] - pub shell: Option, - - /// Inputs for the action. - #[facet(default, skip_serializing_if = Option::is_none)] - pub with: Option>, - - /// Environment variables for this step. - #[facet(default, skip_serializing_if = Option::is_none)] - pub env: Option>, - } -} - -// ============================================================================= -// Helper constructors -// ============================================================================= - -impl Step { - /// Create a step that uses a GitHub Action. - pub fn uses(name: impl Into, action: impl Into) -> Self { - Self { - name: Some(name.into()), - id: None, - uses: Some(action.into()), - run: None, - shell: None, - with: None, - env: None, - } - } - - /// Create a step that runs a shell command. - pub fn run(name: impl Into, command: impl Into) -> Self { - Self { - name: Some(name.into()), - id: None, - uses: None, - run: Some(command.into()), - shell: None, - with: None, - env: None, - } - } - - /// Set the step ID. - pub fn id(mut self, id: impl Into) -> Self { - self.id = Some(id.into()); - self - } - - /// Set the shell. - pub fn shell(mut self, shell: impl Into) -> Self { - self.shell = Some(shell.into()); - self - } - - /// Add inputs to this step. - pub fn with_inputs( - mut self, - inputs: impl IntoIterator, impl Into)>, - ) -> Self { - let map: IndexMap = inputs - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(); - self.with = Some(map); - self - } - - /// Add environment variables to this step. - pub fn with_env( - mut self, - env: impl IntoIterator, impl Into)>, - ) -> Self { - let map: IndexMap = - env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); - self.env = Some(map); - self - } -} - -impl Job { - /// Create a new job with a single runner. - pub fn new(runs_on: impl Into) -> Self { - Self { - name: None, - runs_on: RunsOn::single(runs_on), - container: None, - timeout_minutes: None, - needs: None, - if_condition: None, - outputs: None, - env: None, - continue_on_error: None, - permissions: None, - steps: Vec::new(), - } - } - - /// Create a new job with a specific runner configuration. - pub fn with_runner(runs_on: RunsOn) -> Self { - Self { - name: None, - runs_on, - container: None, - timeout_minutes: None, - needs: None, - if_condition: None, - outputs: None, - env: None, - continue_on_error: None, - permissions: None, - steps: Vec::new(), - } - } - - /// Set the timeout in minutes. - pub fn timeout(mut self, minutes: u32) -> Self { - self.timeout_minutes = Some(minutes); - self - } - - /// Set the display name for this job. - pub fn name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - /// Run this job inside a container. - pub fn container( - mut self, - image: impl Into, - volumes: impl IntoIterator>, - ) -> Self { - self.container = Some(Container { - image: image.into(), - volumes: Some(volumes.into_iter().map(Into::into).collect()), - options: None, - }); - self - } - - /// Set container runtime options. - pub fn container_options(mut self, options: impl Into) -> Self { - let container = self - .container - .as_mut() - .expect("container_options called before container"); - container.options = Some(options.into()); - self - } - - /// Add dependencies to this job. - pub fn needs(mut self, deps: impl IntoIterator>) -> Self { - self.needs = Some(deps.into_iter().map(Into::into).collect()); - self - } - - /// Set the condition for running this job. - pub fn if_condition(mut self, condition: impl Into) -> Self { - self.if_condition = Some(condition.into()); - self - } - - /// Set outputs for this job. - pub fn outputs( - mut self, - outputs: impl IntoIterator, impl Into)>, - ) -> Self { - self.outputs = Some( - outputs - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ); - self - } - - /// Set environment variables for this job. - pub fn env( - mut self, - env: impl IntoIterator, impl Into)>, - ) -> Self { - self.env = Some(env.into_iter().map(|(k, v)| (k.into(), v.into())).collect()); - self - } - - /// Allow this job to fail without failing the workflow. - pub fn continue_on_error(mut self, enabled: bool) -> Self { - self.continue_on_error = Some(enabled); - self - } - - /// Set permissions for this job (overrides workflow-level permissions). - pub fn permissions( - mut self, - perms: impl IntoIterator, impl Into)>, - ) -> Self { - self.permissions = Some( - perms - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ); - self - } - - /// Add steps to this job. - pub fn steps(mut self, steps: impl IntoIterator) -> Self { - self.steps = steps.into_iter().collect(); - self - } -} - -// ============================================================================= -// Common step patterns -// ============================================================================= - -const BUILD_DDC_COMMAND: &str = r#"set -euo pipefail -if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" -fi -cargo build --release --bin ddc --verbose -actual="$(target/release/ddc --version)" -echo "$actual" -if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - expected="ddc ${GITHUB_REF_NAME#v}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi -fi"#; - -const BUILD_BROWSER_ASSETS_COMMAND: &str = r#"set -euo pipefail -scripts/build-browser-assets.sh"#; - -const PREPARE_DDC_DIST_COMMAND: &str = r#"set -euo pipefail -rm -rf dist -mkdir -p dist -cp target/release/ddc dist/ddc -chmod +x dist/ddc -scripts/stage-browser-assets.sh dist/dodeca-assets -DODECA_ASSETS_DIR=dist/dodeca-assets dist/ddc assets --packaged --fail -ls -laR dist"#; - -const TEST_DDC_COMMAND: &str = r#"set -euo pipefail -if [[ "${GITHUB_REF_TYPE:-}" == "tag" && -n "${GITHUB_REF_NAME:-}" ]]; then - export DODECA_RELEASE_VERSION="${GITHUB_REF_NAME}" -fi -cargo test --release --bin ddc"#; - -pub mod common { - use super::*; - - pub fn checkout(platform: CiPlatform) -> Step { - Step::uses("Checkout", platform.checkout_action()) - } - - pub fn install_rust(platform: CiPlatform) -> Step { - Step::uses("Install Rust", platform.rust_toolchain_action()) - .with_inputs([("toolchain", CiPlatform::RUST_TOOLCHAIN)]) - } - - pub fn install_rust_with_target(platform: CiPlatform, target: &str) -> Step { - Step::uses("Install Rust", platform.rust_toolchain_action()).with_inputs([ - ("toolchain", CiPlatform::RUST_TOOLCHAIN), - ("targets", target), - ]) - } - - pub fn install_rust_with_components_and_target( - platform: CiPlatform, - components: &str, - target: &str, - ) -> Step { - Step::uses("Install Rust", platform.rust_toolchain_action()).with_inputs([ - ("toolchain", CiPlatform::RUST_TOOLCHAIN), - ("components", components), - ("targets", target), - ]) - } - - pub fn rust_cache_with_targets( - platform: CiPlatform, - cache_targets: bool, - target: &Target, - ) -> Step { - let mut inputs: Vec<(&str, &str)> = vec![ - ("cache-on-failure", "true"), - ( - "cache-targets", - if cache_targets { "true" } else { "false" }, - ), - ]; - - // macOS requires cache-bin: false to avoid cache corruption issues - if target.triple.contains("apple") { - inputs.push(("cache-bin", "false")); - } - - Step::uses("Rust cache", platform.rust_cache_action()).with_inputs(inputs) - } - - pub fn local_cache_with_targets( - platform: CiPlatform, - cache_targets: bool, - job_suffix: &str, - base_path: &str, - ) -> Step { - // Use a stable key based on OS and job type, not Cargo.lock hash. - // This maximizes cache reuse - Cargo's incremental compilation handles - // dependency changes well, and a stale cache is better than no cache. - let key = if cache_targets { - format!("${{{{ runner.os }}}}-cargo-targets-{}", job_suffix) - } else { - format!("${{{{ runner.os }}}}-cargo-{}", job_suffix) - }; - - let action = platform.local_cache_action(); - - if platform.uses_local_cache() { - // GitHub: use bearcove/local-cache with base path - Step::uses("Local cache", action).with_inputs([ - ("path", "target"), - ("key", &key), - ("base", base_path), - ]) - } else { - // Forgejo: use standard cache action (no base path, different restore-keys format) - let restore_key = "${{ runner.os }}-cargo-".to_string(); - Step::uses("Cache", action).with_inputs([ - ("path", "target"), - ("key", &key), - ("restore-keys", &restore_key), - ]) - } - } - - pub fn upload_artifact( - platform: CiPlatform, - name: impl Into, - path: impl Into, - ) -> Step { - Step::uses("Upload artifact", platform.upload_artifact_action()) - .with_inputs([("name", name.into()), ("path", path.into())]) - } - - pub fn download_artifact( - platform: CiPlatform, - name: impl Into, - path: impl Into, - ) -> Step { - Step::uses("Download artifact", platform.download_artifact_action()) - .with_inputs([("name", name.into()), ("path", path.into())]) - } - - pub fn download_all_artifacts(platform: CiPlatform, path: impl Into) -> Step { - Step::uses( - "Download all artifacts", - platform.download_artifact_action(), - ) - .with_inputs([ - ("path", path.into()), - ("pattern", "build-*".to_string()), - ("merge-multiple", "true".to_string()), - ]) - } - - // ========================================================================= - // ctree-based local cache helpers (for Forgejo self-hosted runners) - // ========================================================================= - - /// Generate a ctree-based cache restore step. - /// Uses reflinks for near-instant COW copies on supported filesystems. - /// - /// After restore, we nuke any CMake build directories because CMake caches - /// are not relocatable - they contain absolute paths that break when the - /// workspace path changes between CI runs. - pub fn ctree_cache_restore(cache_name: &str, base_path: &str) -> Step { - let cache_dir = format!("{}/dodeca-ci/{}", base_path, cache_name); - Step::run( - "Restore cache (ctree)", - format!( - r#"if [ -d "{cache_dir}" ]; then - echo "Restoring cache from {cache_dir}..." - rm -rf target 2>/dev/null || true - ctree "{cache_dir}" target && echo "Cache restored via ctree from {cache_dir}" || echo "ctree failed, starting fresh" - du -sh target 2>/dev/null || true - echo "=== Cache contents after restore ===" - tree -ah -L 2 target/ 2>/dev/null || find target -maxdepth 2 -type d - - # CMake build directories are not relocatable - they contain absolute paths. - # Nuke them to force a fresh CMake configure on path changes. - find target -path '*/build/*/out/build/CMakeCache.txt' -delete 2>/dev/null || true - find target -path '*/build/*/out/build/CMakeFiles' -type d -exec rm -rf {{}} + 2>/dev/null || true - echo "Cleaned CMake caches (non-relocatable)" - -else - echo "No cache found at {cache_dir}" -fi"# - ), - ) - } - - /// Restore source mtimes for unchanged files using timelord. - pub fn timelord_restore(base_path: &str) -> Step { - let cache_dir = format!("{}/dodeca-ci/timelord", base_path); - Step::run( - "Restore source mtimes (timelord)", - format!(r#"timelord sync --source-dir . --cache-dir "{cache_dir}""#), - ) - } - - /// Print timelord cache info (if present). - pub fn timelord_cache_info(base_path: &str, label: &str) -> Step { - let cache_dir = format!("{}/dodeca-ci/timelord", base_path); - Step::run( - format!("Timelord cache info ({label})"), - format!( - r#"if [ -d "{cache_dir}" ]; then - timelord cache-info --cache-dir "{cache_dir}" -else - echo "No timelord cache dir at {cache_dir}" -fi"# - ), - ) - } - - /// Stamp cargo sweep to track artifact usage for the cache directory. - pub fn cargo_sweep_cache_stamp(cache_name: &str, base_path: &str) -> Step { - let cache_dir = format!("{}/dodeca-ci/{}", base_path, cache_name); - Step::run( - "Stamp cache artifacts (cargo sweep)", - format!( - r#"mkdir -p "{cache_dir}" -CARGO_TARGET_DIR="{cache_dir}" cargo sweep --stamp"# - ), - ) - } - - /// Trim the saved cache based on the sweep stamp. - pub fn cargo_sweep_cache_trim(cache_name: &str, base_path: &str) -> Step { - let cache_dir = format!("{}/dodeca-ci/{}", base_path, cache_name); - Step::run( - "Trim cache (cargo sweep)", - format!( - r#"if [ -d "{cache_dir}" ]; then - CARGO_TARGET_DIR="{cache_dir}" cargo sweep --file || true -else - echo "No cache found at {cache_dir} to trim" -fi"# - ), - ) - } - - /// Generate a ctree-based cache save step. - /// Runs at end of job to persist target/ directory. - pub fn ctree_cache_save(cache_name: &str, base_path: &str) -> Step { - let cache_dir = format!("{}/dodeca-ci/{}", base_path, cache_name); - Step::run( - "Save cache (ctree)", - format!( - r#"echo "=== Cache contents before save ===" -du -sh target 2>/dev/null || true -tree -ah -L 2 target/ 2>/dev/null || find target -maxdepth 2 -type d -mkdir -p "$(dirname "{cache_dir}")" -rm -rf "{cache_dir}" 2>/dev/null || true -ctree target "{cache_dir}" && echo "Cache saved via ctree to {cache_dir}" || echo "ctree save failed (non-fatal)""# - ), - ) - } - - // ========================================================================= - // SSH-based artifact helpers (for Forgejo) - Content-Addressed Storage - // ========================================================================= - // - // Artifacts are stored using content-addressed storage (CAS) over SSH for deduplication: - // - Actual files go to: /srv/cas/cas/sha256// - // - Pointer files go to: /srv/cas/cas/pointers/ci// (contains just the hash) - // - // This means identical binaries across runs are only stored once. - - /// Generate an S3 artifact upload step using content-addressed storage. - /// The file is uploaded to `cas/`, and a pointer file is written to `ci//`. - #[allow(dead_code)] - pub fn s3_upload_artifact(name: &str, path: &str, run_id_var: &str) -> Step { - Step::run( - format!("Upload {} to S3", name), - format!( - r#"HASH=$(sha256sum "{path}" | cut -d' ' -f1) -CAS_KEY="cas/$HASH" -# Check if already in CAS -if ! aws s3 --endpoint-url "$S3_ENDPOINT" ls "s3://${{{{S3_BUCKET}}}}/$CAS_KEY" > /dev/null 2>&1; then - aws s3 --endpoint-url "$S3_ENDPOINT" cp "{path}" "s3://${{{{S3_BUCKET}}}}/$CAS_KEY" - echo "Uploaded {name} to CAS ($HASH)" -else - echo "Skipped {name} (already in CAS: $HASH)" -fi -# Write pointer file -echo "$HASH" | aws s3 --endpoint-url "$S3_ENDPOINT" cp - "s3://${{{{S3_BUCKET}}}}/ci/{run_id}/{name}""#, - run_id = run_id_var - ), - ) - } - - /// Generate an S3 artifact upload step for multiple files using content-addressed storage. - #[allow(dead_code)] - pub fn s3_upload_artifacts(name_prefix: &str, paths: &[String], run_id_var: &str) -> Step { - let mut script = String::new(); - for path in paths { - let basename = std::path::Path::new(path) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(path); - script.push_str(&format!( - r#"HASH=$(sha256sum "{path}" | cut -d' ' -f1) -CAS_KEY="cas/$HASH" -if ! aws s3 --endpoint-url "$S3_ENDPOINT" ls "s3://${{S3_BUCKET}}/$CAS_KEY" > /dev/null 2>&1; then - aws s3 --endpoint-url "$S3_ENDPOINT" cp "{path}" "s3://${{S3_BUCKET}}/$CAS_KEY" - echo "Uploaded {basename} to CAS ($HASH)" -else - echo "Skipped {basename} (already in CAS)" -fi -echo "$HASH" | aws s3 --endpoint-url "$S3_ENDPOINT" cp - "s3://${{S3_BUCKET}}/ci/{run_id}/{name_prefix}/{basename}" -"#, - run_id = run_id_var - )); - } - script.push_str(&format!( - r#"echo "Processed {} files for {name_prefix}""#, - paths.len() - )); - Step::run(format!("Upload {} to S3", name_prefix), script) - } - - /// Generate an S3 artifact download step using content-addressed storage. - /// Reads the pointer file to get the hash, then downloads from CAS. - #[allow(dead_code)] - pub fn s3_download_artifact(name: &str, dest_path: &str, run_id_var: &str) -> Step { - Step::run( - format!("Download {} from S3", name), - format!( - r#"mkdir -p "$(dirname "{dest_path}")" -HASH=$(aws s3 --endpoint-url "$S3_ENDPOINT" cp "s3://${{{{S3_BUCKET}}}}/ci/{run_id}/{name}" -) -aws s3 --endpoint-url "$S3_ENDPOINT" cp "s3://${{{{S3_BUCKET}}}}/cas/$HASH" "{dest_path}" -echo "Downloaded {name} from CAS ($HASH)""#, - run_id = run_id_var - ), - ) - } - - /// Generate an S3 artifact download step for a prefix using content-addressed storage. - /// Lists pointer files, reads each hash, and downloads from CAS. - #[allow(dead_code)] - pub fn s3_download_artifacts_prefix(prefix: &str, dest_dir: &str, run_id_var: &str) -> Step { - Step::run( - format!("Download {} from S3", prefix), - format!( - r#"mkdir -p "{dest_dir}" -# List all pointer files under the prefix -aws s3 --endpoint-url "$S3_ENDPOINT" ls "s3://${{{{S3_BUCKET}}}}/ci/{run_id}/{prefix}/" | while read -r line; do - FILENAME=$(echo "$line" | awk '{{print $4}}') - if [ -n "$FILENAME" ]; then - HASH=$(aws s3 --endpoint-url "$S3_ENDPOINT" cp "s3://${{{{S3_BUCKET}}}}/ci/{run_id}/{prefix}/$FILENAME" -) - aws s3 --endpoint-url "$S3_ENDPOINT" cp "s3://${{{{S3_BUCKET}}}}/cas/$HASH" "{dest_dir}/$FILENAME" - echo "Downloaded $FILENAME from CAS ($HASH)" - fi -done"#, - run_id = run_id_var - ), - ) - } - - /// Generate CAS environment variables for SSH-based content-addressed storage. - pub fn cas_env() -> IndexMap { - [("CAS_SSH_KEY", "${{ secrets.CAS_SSH_KEY }}")] - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - /// Generate S3 environment variables for a step (legacy, for reference). - /// Supports S3-compatible storage (Hetzner, MinIO, etc.) via S3_ENDPOINT. - #[allow(dead_code)] - pub fn s3_env() -> IndexMap { - [ - ("AWS_ACCESS_KEY_ID", "${{ secrets.S3_ACCESS_KEY }}"), - ("AWS_SECRET_ACCESS_KEY", "${{ secrets.S3_SECRET_KEY }}"), - ("S3_ENDPOINT", "${{ secrets.S3_ENDPOINT }}"), - ("S3_BUCKET", "${{ secrets.S3_BUCKET }}"), - ] - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - /// Generate an aws s3 command with the endpoint URL. - pub fn s3_cmd(args: &str) -> String { - format!(r#"aws s3 --endpoint-url "${{S3_ENDPOINT}}" {args}"#) - } - - /// Generate a shell script to verify the required release artifacts exist. - pub fn verify_artifacts_script() -> String { - let mut script = String::new(); - script.push_str("#!/bin/bash\nset -euo pipefail\n\n"); - script.push_str("echo 'Verifying all required artifacts exist in dist/'\n"); - script.push_str("missing=0\n\n"); - - // Check main binary - script.push_str("if [[ ! -x dist/ddc ]]; then\n"); - script.push_str(" echo '❌ MISSING: ddc'\n"); - script.push_str(" missing=1\n"); - script.push_str("else\n"); - script.push_str(" echo '✓ ddc'\n"); - script.push_str("fi\n\n"); - - script.push_str("if [[ $missing -eq 1 ]]; then\n"); - script.push_str(" echo ''\n"); - script.push_str(" echo 'ERROR: Some required binaries are missing!'\n"); - script.push_str(" echo 'This usually means a ddc build job failed or artifact upload was incomplete.'\n"); - script.push_str(" exit 1\n"); - script.push_str("fi\n\n"); - script.push_str("echo ''\n"); - script.push_str("echo 'All artifacts verified.'\n"); - - script - } -} - -// ============================================================================= -// CI workflow builder (for PRs and main branch) -// ============================================================================= - -/// CI runner configuration. -struct CiRunner { - runner: RunnerSpec, -} - -/// Get the CI Linux runner configuration for a platform. -fn ci_linux_runner(platform: CiPlatform) -> CiRunner { - let runner = linux_runner(platform); - - CiRunner { runner } -} - -/// Build the unified CI workflow (runs on PRs, main branch, and tags). -/// -/// Strategy: -/// - Fan-out: Build ddc for each target platform -/// - Integration: Run integration tests -/// - Release: On tags, publish release (GitHub only) -pub fn build_ci_workflow(platform: CiPlatform, _repo_root: &Utf8Path) -> Workflow { - use common::*; - - let ci_linux = ci_linux_runner(platform); - let targets = targets_for_platform(platform); - - let mut jobs = IndexMap::new(); - - // Track jobs required before release. - let mut all_release_needs: Vec = Vec::new(); - - // Verify generated CI files are up to date (no dependencies, runs in parallel with everything) - jobs.insert( - "check-ci".to_string(), - Job::with_runner(ci_linux_runner(platform).runner.to_runs_on()) - .name("Check CI up to date") - .timeout(10) - .steps([ - checkout(platform), - install_rust(platform), - if ci_linux_runner(platform).runner.is_self_hosted() { - local_cache_with_targets(platform, false, "check-ci", "/home/amos/.cache") - } else { - rust_cache_with_targets( - platform, - false, - targets - .iter() - .find(|t| t.triple == "x86_64-unknown-linux-gnu") - .expect("Linux target should exist"), - ) - }, - Step::run( - "Check CI files are up to date", - "cargo xtask ci-github --check && cargo xtask ci-forgejo --check", - ), - ]), - ); - - // Cache step for CI Linux jobs - let linux_target = targets - .iter() - .find(|t| t.triple == "x86_64-unknown-linux-gnu") - .expect("Linux target should exist"); - let ci_linux_cache = if ci_linux.runner.is_self_hosted() { - local_cache_with_targets(platform, true, "ci-linux", "/home/amos/.cache") - } else { - rust_cache_with_targets(platform, true, linux_target) - }; - - // Clippy is compile-only; browser JS/WASM assets are built only by jobs - // that run browser behavior or package archives. - jobs.insert( - "clippy".to_string(), - Job::with_runner(ci_linux.runner.to_runs_on()) - .name("Clippy") - .timeout(30) - .continue_on_error(true) - .steps([ - checkout(platform), - install_rust_with_components_and_target( - platform, - "clippy", - "wasm32-unknown-unknown", - ), - ci_linux_cache.clone(), - Step::run( - "Clippy", - "cargo clippy --all-features --all-targets -- -D warnings", - ), - ]), - ); - - // Every-commit compile checks on free GitHub-hosted macOS/Windows runners. - // Compile signal only, no packaging/browser asset work. - if platform == CiPlatform::GitHub { - let macos_check_target = Target { - triple: "aarch64-apple-darwin", - os: GITHUB_MACOS_CHECK_RUNNER, - runner: RunnerSpec::single(GITHUB_MACOS_CHECK_RUNNER), - archive_ext: "tar.xz", - }; - let windows_check_target = Target { - triple: "x86_64-pc-windows-msvc", - os: GITHUB_WINDOWS_CHECK_RUNNER, - runner: RunnerSpec::single(GITHUB_WINDOWS_CHECK_RUNNER), - archive_ext: "zip", - }; - - jobs.insert( - "check-macos".to_string(), - Job::with_runner(macos_check_target.runs_on()) - .name("Check macOS") - .timeout(30) - .steps([ - checkout(platform), - install_rust(platform), - rust_cache_with_targets(platform, true, &macos_check_target), - Step::run("Check macOS", "cargo check --workspace --all-targets"), - ]), - ); - - jobs.insert( - "check-windows".to_string(), - Job::with_runner(windows_check_target.runs_on()) - .name("Check Windows") - .timeout(30) - .steps([ - checkout(platform), - install_rust(platform), - rust_cache_with_targets(platform, true, &windows_check_target), - Step::run("Check Windows", "cargo check --workspace --all-targets"), - ]), - ); - } - - // Linux carries the real validation on every commit: build ddc (needed by - // integration + browser tests), integration tests, and browser tests. - for target in &targets { - if target.triple != "x86_64-unknown-linux-gnu" { - continue; - } - let short = target.short_name(); - let workspace_var = platform.context_var("workspace"); - - // Build ddc for integration + browser tests. No archive/upload here; - // release archives are produced by tag-only package jobs. - let ddc_job_id = format!("build-ddc-{short}"); - jobs.insert( - ddc_job_id.clone(), - Job::with_runner(target.runs_on()) - .name(format!("Build ddc ({short})")) - .timeout(30) - .steps([ - checkout(platform), - install_rust(platform), - Step::uses("Setup Node.js", "actions/setup-node@v4") - .with_inputs([("node-version", "20")]), - Step::uses("Install wasm-pack", platform.wasm_pack_action()) - .with_inputs([("version", "latest")]), - if target.is_self_hosted() { - local_cache_with_targets( - platform, - true, - &format!("ddc-{}", short), - target.cache_base_path(), - ) - } else { - rust_cache_with_targets(platform, true, target) - }, - Step::run("Install pnpm", "npm install -g pnpm"), - Step::run("Build ddc", BUILD_DDC_COMMAND).shell("bash"), - Step::run("Test ddc", TEST_DDC_COMMAND).shell("bash"), - Step::run("Build browser assets", BUILD_BROWSER_ASSETS_COMMAND).shell("bash"), - Step::run("Prepare ddc dist", PREPARE_DDC_DIST_COMMAND).shell("bash"), - upload_artifact(platform, format!("ddc-{short}"), "dist"), - ]), - ); - - let integration_job_id = format!("integration-{short}"); - jobs.insert( - integration_job_id.clone(), - Job::with_runner(target.runs_on()) - .name(format!("Integration ({short})")) - .timeout(30) - .needs([ddc_job_id.clone()]) - .steps([ - checkout(platform), - install_rust(platform), - if target.is_self_hosted() { - local_cache_with_targets( - platform, - true, - &format!("integration-{}", short), - target.cache_base_path(), - ) - } else { - rust_cache_with_targets(platform, true, target) - }, - Step::run( - "Build integration-tests", - "cargo build --package integration-tests", - ), - Step::uses("Download ddc", platform.download_artifact_action()) - .with_inputs([("name", format!("ddc-{short}")), ("path", "dist".into())]), - Step::run("Prepare artifacts", "chmod +x dist/ddc && ls -la dist/"), - Step::run("Verify artifacts", verify_artifacts_script()), - Step::run( - "Run integration tests", - "cargo xtask integration --no-build", - ) - .with_env([ - ("DODECA_BIN", format!("{}/dist/ddc", workspace_var)), - ( - "DODECA_TEST_FIXTURES_DIR", - format!("{}/crates/integration-tests/fixtures", workspace_var), - ), - ]), - ]), - ); - all_release_needs.push(integration_job_id.clone()); - - // Browser tests (Linux only) - tests livereload and DOM patching in real browser - let browser_tests_job_id = format!("browser-tests-{short}"); - jobs.insert( - browser_tests_job_id, - Job::with_runner(target.runs_on()) - .name(format!("Browser Tests ({short})")) - .timeout(30) - .needs([ddc_job_id.clone()]) - .steps([ - checkout(platform), - Step::uses("Download ddc", platform.download_artifact_action()) - .with_inputs([("name", format!("ddc-{short}")), ("path", "dist".into())]), - Step::run("Prepare artifacts", "chmod +x dist/ddc && ls -la dist/"), - Step::uses("Setup Node.js", "actions/setup-node@v4") - .with_inputs([("node-version", "20")]), - Step::run( - "Install browser test dependencies", - "cd browser-tests && npm ci && npx playwright install chromium --with-deps", - ), - Step::run("Run browser tests", "cd browser-tests && npm test") - .with_env([("DODECA_BIN", format!("{}/dist/ddc", workspace_var))]), - ]), - ); - } - - // Tag-only packaging. Binaries should be ready soon, so macOS uses the bigger - // Blacksmith runner; Linux stays on the bearcove pool (fixed cost, already fast). - if platform == CiPlatform::GitHub { - let linux_pkg = "package-linux-x64".to_string(); - jobs.insert( - linux_pkg.clone(), - Job::with_runner(linux_runner(platform).to_runs_on()) - .name("Package (linux-x64)") - .timeout(30) - .if_condition("startsWith(github.ref, 'refs/tags/')") - .steps([ - checkout(platform), - install_rust(platform), - Step::uses("Setup Node.js", "actions/setup-node@v4") - .with_inputs([("node-version", "20")]), - Step::uses("Install wasm-pack", platform.wasm_pack_action()) - .with_inputs([("version", "latest")]), - rust_cache_with_targets(platform, true, linux_target), - Step::run("Install pnpm", "npm install -g pnpm"), - Step::run("Build browser assets", BUILD_BROWSER_ASSETS_COMMAND).shell("bash"), - Step::run("Build ddc", BUILD_DDC_COMMAND).shell("bash"), - Step::run( - "Assemble archive", - "bash scripts/assemble-archive.sh x86_64-unknown-linux-gnu", - ), - upload_artifact( - platform, - "build-linux-x64", - "dodeca-x86_64-unknown-linux-gnu.tar.xz", - ), - ]), - ); - all_release_needs.push(linux_pkg); - - let mac_pkg = "package-macos-arm64".to_string(); - jobs.insert( - mac_pkg.clone(), - Job::with_runner(RunnerSpec::single(GITHUB_MACOS_PACKAGE_RUNNER).to_runs_on()) - .name("Package (macos-arm64)") - .timeout(30) - .if_condition("startsWith(github.ref, 'refs/tags/')") - .steps([ - checkout(platform), - install_rust(platform), - Step::uses("Setup Node.js", "actions/setup-node@v4") - .with_inputs([("node-version", "20")]), - Step::uses("Install wasm-pack", platform.wasm_pack_action()) - .with_inputs([("version", "latest")]), - Step::run("Install pnpm", "npm install -g pnpm"), - Step::run("Build browser assets", BUILD_BROWSER_ASSETS_COMMAND).shell("bash"), - Step::run("Build ddc", BUILD_DDC_COMMAND).shell("bash"), - Step::run( - "Install xz (macOS)", - "if ! command -v xz >/dev/null 2>&1 && command -v brew >/dev/null 2>&1; then HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew install xz; fi", - ), - Step::run( - "Assemble archive", - "bash scripts/assemble-archive.sh aarch64-apple-darwin", - ), - upload_artifact( - platform, - "build-macos-arm64", - "dodeca-aarch64-apple-darwin.tar.xz", - ), - ]), - ); - all_release_needs.push(mac_pkg); - - let win_pkg = "package-windows-x64".to_string(); - jobs.insert( - win_pkg.clone(), - Job::with_runner(RunnerSpec::single(GITHUB_WINDOWS_PACKAGE_RUNNER).to_runs_on()) - .name("Package (windows-x64)") - .timeout(30) - .if_condition("startsWith(github.ref, 'refs/tags/')") - .steps([ - checkout(platform), - install_rust(platform), - Step::uses("Setup Node.js", "actions/setup-node@v4") - .with_inputs([("node-version", "20")]), - Step::uses("Install wasm-pack", platform.wasm_pack_action()) - .with_inputs([("version", "latest")]), - Step::run("Install pnpm", "npm install -g pnpm"), - Step::run("Build browser assets", BUILD_BROWSER_ASSETS_COMMAND).shell("bash"), - Step::run("Build ddc", BUILD_DDC_COMMAND).shell("bash"), - Step::run( - "Assemble archive", - "bash scripts/assemble-archive.sh x86_64-pc-windows-msvc", - ), - upload_artifact( - platform, - "build-windows-x64", - "dodeca-x86_64-pc-windows-msvc.zip", - ), - ]), - ); - all_release_needs.push(win_pkg); - } - - // Release job (GitHub only - Forgejo uses build_forgejo_workflow) - let ref_name_var = platform.context_var("ref_name"); - jobs.insert( - "release".into(), - Job::new("ubuntu-latest") - .name("Release") - .timeout(30) - .needs(all_release_needs) - .if_condition("startsWith(github.ref, 'refs/tags/')") - .permissions([("contents", "write")]) - .env([ - ("GH_TOKEN", "${{ secrets.GITHUB_TOKEN }}"), - ("HOMEBREW_TAP_TOKEN", "${{ secrets.HOMEBREW_TAP_TOKEN }}"), - ]) - .steps([ - checkout(platform), - download_all_artifacts(platform, "dist"), - Step::run("List artifacts (before flatten)", "ls -laR dist/"), - Step::run( - "Flatten artifact directories", - "find dist -mindepth 2 -type f -exec mv -t dist {} + 2>/dev/null || true; find dist -mindepth 1 -type d -empty -delete 2>/dev/null || true", - ), - Step::run("List artifacts (after flatten)", "ls -la dist/"), - Step::run( - "Create GitHub Release", - format!( - r#"shopt -s nullglob -# Rename install.sh to dodeca-installer.sh for the release -cp install.sh dist/dodeca-installer.sh -gh release create "{ref_name}" \ - --title "dodeca {ref_name}" \ - --generate-notes \ - dist/*.tar.xz dist/*.zip dist/dodeca-installer.sh"#, - ref_name = ref_name_var - ), - ) - .shell("bash"), - Step::run( - "Update Homebrew tap", - format!( - r#"bash scripts/update-homebrew.sh "{ref_name}""#, - ref_name = ref_name_var - ), - ) - .shell("bash"), - ]), - ); - - Workflow { - name: "CI".into(), - on: On { - push: Some(PushTrigger { - tags: Some(vec!["v*".into()]), - branches: Some(vec!["main".into()]), - }), - pull_request: Some(PullRequestTrigger { - branches: Some(vec!["main".into()]), - }), - merge_group: Some(MergeGroupTrigger { - branches: Some(vec!["main".into()]), - }), - workflow_dispatch: Some(WorkflowDispatchTrigger {}), - }, - permissions: Some( - [("contents", "read")] - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ), - env: Some( - [("CARGO_TERM_COLOR", "always")] - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ), - jobs, - } -} - -// ============================================================================= -// Forgejo CI workflow builder (two fat vixen-ci jobs) -// ============================================================================= - -/// Build the Forgejo CI workflow. -/// -/// Forgejo has one trusted Linux runner and one macOS runner. Keep the workflow -/// coarse: each platform builds, tests, assembles, and uploads only its final -/// archive. Linux uses the shared vixen-ci image and mounted cache; macOS uses -/// the same helper from its public copy. -pub fn build_forgejo_workflow(_repo_root: &Utf8Path) -> Workflow { - use common::*; - - let platform = CiPlatform::Forgejo; - let targets = targets_for_platform(platform); - let mut jobs = IndexMap::new(); - - for target in &targets { - let short = target.short_name(); - let is_linux = target.triple == "x86_64-unknown-linux-gnu"; - let job_id = if is_linux { - "forgejo-linux-x64" - } else { - "forgejo-macos-arm64" - }; - - let archive_name = format!("dodeca-{}.{}", target.triple, target.archive_ext); - let stable_key = format!("dodeca-{short}"); - let cargo_incremental = if is_linux { "1" } else { "0" }; - let maybe_check_ci = if is_linux { - "cargo xtask ci-forgejo --check\n" - } else { - "" - }; - let maybe_clean_incremental = if is_linux { - "" - } else { - r#"rm -rf "$CARGO_TARGET_DIR"/release/incremental "$CARGO_TARGET_DIR"/debug/incremental -"# - }; - let maybe_install_wasm_pack = r#"if ! command -v wasm-pack >/dev/null 2>&1; then - cargo install wasm-pack --locked -fi -"#; - // The DevTools UI bundle serves /_/devtools/* with pnpm. vixen-ci ships - // node but not pnpm, so without this build.rs silently ships an empty - // DevTools/editor bundle and /_dodeca/edit/ is a blank page. - // Linux is the release-critical platform (the kb installs the linux - // ddc), so install loudly there; macOS is best-effort. - let maybe_install_pnpm = if is_linux { - "npm install -g pnpm\n" - } else { - "command -v pnpm >/dev/null 2>&1 || npm install -g pnpm || corepack enable || true\n" - }; - let maybe_browser_tests = if is_linux { - r#"DODECA_BIN="$STABLE_SRC/target/release/ddc" \ - bash -lc 'cd "$STABLE_SRC/browser-tests" && npm ci && npx playwright install chromium --with-deps && npm test' -"# - } else { - "" - }; - let maybe_xz_install = if target.triple.contains("apple") { - r#"if ! command -v xz >/dev/null 2>&1 && command -v brew >/dev/null 2>&1; then - HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew install xz -fi -"# - } else { - "" - }; - - let install_helper = Step::run( - "Install vixen-ci", - r#"mkdir -p "$RUNNER_TEMP/vixen-ci" -curl -fsSL https://vixen-misc.s3-website.fr-par.scw.cloud/vixen-ci/latest/vixen-ci -o "$RUNNER_TEMP/vixen-ci/vixen-ci" -chmod +x "$RUNNER_TEMP/vixen-ci/vixen-ci" -echo "$RUNNER_TEMP/vixen-ci" >> "$GITHUB_PATH""#, - ); - let install_rsync = if is_linux { - Step::run( - "Install Linux build tools", - "apt-get update && apt-get install -y --no-install-recommends cmake rsync", - ) - } else { - Step::run("Check rsync", "rsync --version >/dev/null") - }; - - let prepare_stable_source = Step::run( - "Prepare stable source", - format!( - r#"vixen-ci stable-src {stable_key} \ - --exclude=/crates/dodeca-devtools/pkg/ \ - --exclude=/crates/dodeca-search-wasm/pkg/ -export STABLE_SRC="$HOME/.cache/vixen/{stable_key}-src" -export CARGO_TARGET_DIR="$HOME/.cache/vixen/{stable_key}-target" -cd "$STABLE_SRC" -rm -rf target -ln -s "$CARGO_TARGET_DIR" target -{maybe_clean_incremental} -echo "STABLE_SRC=$STABLE_SRC" -echo "CARGO_TARGET_DIR=$CARGO_TARGET_DIR""# - ), - ); - - let build_and_test = Step::run( - "Build and test", - format!( - r#"set -euo pipefail -cd "$STABLE_SRC" -{maybe_check_ci} -rustup target add wasm32-unknown-unknown -{maybe_install_wasm_pack}{maybe_install_pnpm}scripts/build-browser-assets.sh -cargo nextest run -cargo xtask integration -if [[ "${{GITHUB_REF_TYPE:-}}" == "tag" && -n "${{GITHUB_REF_NAME:-}}" ]]; then - export DODECA_RELEASE_VERSION="${{GITHUB_REF_NAME}}" -fi -cargo build --release -actual="$(target/release/ddc --version)" -echo "$actual" -if [[ "${{GITHUB_REF_TYPE:-}}" == "tag" && -n "${{GITHUB_REF_NAME:-}}" ]]; then - expected="ddc ${{GITHUB_REF_NAME#v}}" - if [[ "$actual" != "$expected" ]]; then - echo "Expected '$expected', got '$actual'" >&2 - exit 1 - fi -fi -rm -rf dist -mkdir -p dist -cp target/release/ddc dist/ -chmod +x dist/ddc -{verify} -{maybe_browser_tests}"#, - verify = verify_artifacts_script(), - ), - ) - .shell("bash"); - - let assemble = Step::run( - "Assemble archive", - format!( - r#"set -euo pipefail -cd "$STABLE_SRC" -{maybe_xz_install}bash scripts/assemble-archive.sh {triple} -mkdir -p "$GITHUB_WORKSPACE/dist" -cp "{archive_name}" "$GITHUB_WORKSPACE/dist/" -ls -la "$GITHUB_WORKSPACE/dist/""#, - triple = target.triple, - ), - ) - .shell("bash"); - - let mut steps = Vec::new(); - if !is_linux { - steps.push(install_helper); - } - steps.push( - Step::run("Prepare Vixen CI", "vixen-ci prepare") - .with_env([("DEPLOY_KEY", "${{ secrets.DEPLOY_KEY }}")]), - ); - steps.push(Step::run("Checkout", "vixen-ci checkout")); - if !is_linux { - steps.push(Step::run("Rust toolchain", "vixen-ci rust-toolchain")); - steps.push(Step::run("Install nextest", "vixen-ci nextest")); - steps.push(install_rsync); - } - steps.push(prepare_stable_source); - steps.push(build_and_test); - steps.push(assemble); - steps.push(upload_artifact( - platform, - format!("build-{short}"), - format!("dist/{archive_name}"), - )); - - let mut job = Job::with_runner(target.runs_on()) - .name(format!("Forgejo {short}")) - .timeout(90) - .env([ - ("CARGO_INCREMENTAL", cargo_incremental), - ("FORGEJO_TOKEN", "${{ github.token }}"), - ("RUST_BACKTRACE", "1"), - ]) - .steps(steps); - - if is_linux { - job = job - .container( - "code.vixen.rs/vixen/vixen-ci:latest", - ["/srv/ci/cache/dodeca:/srv/ci/cache/dodeca"], - ) - .env([ - ("HOME", "/srv/ci/cache/dodeca/home"), - ("CARGO_HOME", "/srv/ci/cache/dodeca/cargo"), - ("CARGO_INCREMENTAL", cargo_incremental), - ("FORGEJO_TOKEN", "${{ github.token }}"), - ("RUST_BACKTRACE", "1"), - ]); - } - - jobs.insert(job_id.to_string(), job); - } - - // Release publish job. On a tag build only, after both platforms are built, - // download the two archives and push them to the Scaleway bucket the - // installer reads from (scripts/publish-release.sh). This mirrors how - // vixen-ci publishes itself: upload via the S3 API endpoint with - // public-read, served via the website endpoint (RELEASE_BASE_URL). - // Credentials are the ACCESS_KEY_ID / ACCESS_SECRET_KEY Forgejo secrets - // (the Scaleway Object Storage key, same as vixen-ci's publish step). - let publish = Job::with_runner(linux_runner(platform).to_runs_on()) - .name("Publish release") - .timeout(30) - .needs(["forgejo-linux-x64", "forgejo-macos-arm64"]) - .if_condition("startsWith(github.ref, 'refs/tags/')") - .container( - "code.vixen.rs/vixen/vixen-ci:latest", - ["/srv/ci/cache/dodeca:/srv/ci/cache/dodeca"], - ) - .env([ - ("HOME", "/srv/ci/cache/dodeca/home"), - ("FORGEJO_TOKEN", "${{ github.token }}"), - ]) - .steps([ - Step::run( - "Checkout repository", - // Org-agnostic: derive the clone URL from the runner context so - // it keeps working regardless of which Forgejo org owns the - // repo. Only needs install.sh + scripts/, so a shallow fetch of - // the tagged commit is enough. - r#"mkdir -p "$GITHUB_WORKSPACE" -cd "$GITHUB_WORKSPACE" -git init -q -git remote remove origin 2>/dev/null || true -git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" -git -c http.extraHeader="Authorization: token ${FORGEJO_TOKEN}" fetch --depth 1 origin "${GITHUB_SHA}" -git checkout -q --detach FETCH_HEAD"#, - ) - .shell("bash"), - download_artifact(platform, "build-linux-x64", "dist"), - download_artifact(platform, "build-macos-arm64", "dist"), - // Forgejo's download-artifact may nest each artifact in a subdir; - // hoist every file to dist/ so scripts/release.sh finds the tarballs. - Step::run( - "Flatten artifact directories", - "find dist -mindepth 2 -type f -exec mv -t dist {} + 2>/dev/null || true; find dist -mindepth 1 -type d -empty -delete 2>/dev/null || true", - ), - Step::run("List artifacts", "ls -la dist/"), - Step::run("Prepare release artifacts", "bash scripts/release.sh").shell("bash"), - Step::run( - "Publish to object storage", - r#"bash scripts/publish-release.sh "${GITHUB_REF_NAME}""#, - ) - .shell("bash") - .with_env([ - ("AWS_ACCESS_KEY_ID", "${{ secrets.ACCESS_KEY_ID }}"), - ("AWS_SECRET_ACCESS_KEY", "${{ secrets.ACCESS_SECRET_KEY }}"), - ("AWS_DEFAULT_REGION", "fr-par"), - ("AWS_EC2_METADATA_DISABLED", "true"), - ]), - ]); - jobs.insert("publish".to_string(), publish); - - Workflow { - name: "CI".into(), - on: On { - push: Some(PushTrigger { - tags: Some(vec!["v*".into()]), - branches: Some(vec!["main".into()]), - }), - pull_request: Some(PullRequestTrigger { - branches: Some(vec!["main".into()]), - }), - // Forgejo doesn't support merge_group yet - merge_group: None, - workflow_dispatch: Some(WorkflowDispatchTrigger {}), - }, - permissions: Some( - [("contents", "read")] - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ), - env: Some( - [("CARGO_TERM_COLOR", "always")] - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ), - jobs, - } -} - -// ============================================================================= -// Generation -// ============================================================================= - -use camino::Utf8Path; -use eyre::Result; - -const GENERATED_HEADER: &str = - "# GENERATED BY: cargo xtask ci\n# DO NOT EDIT - edit xtask/src/ci.rs instead\n\n"; - -// ============================================================================= -// Installer scripts -// ============================================================================= - -/// Base URL for release artifacts: the `bearcove-dist` Scaleway Object Storage -/// bucket (fr-par), under the `dodeca/releases/` prefix. dodeca is a bearcove -/// project, so it has its own bucket separate from Vixen's. Each release is -/// `//dodeca-.tar.xz`; `/latest` is a text file -/// holding the newest version string. Overridable at install time via -/// `DODECA_BASE_URL` (mirrors / testing). -/// -/// Objects are uploaded public-read and served from the same S3 API endpoint -/// (`s3.fr-par.scw.cloud`); see scripts/publish-release.sh. -pub const RELEASE_BASE_URL: &str = "https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/releases"; - -/// Generate the shell installer script content. -pub fn generate_installer_script() -> String { - // No Rust interpolation needed — everything below is shell. The default - // base URL is injected once so the generator stays the single source. - format!( - r##"#!/bin/sh -# Installer for dodeca -# Usage: curl -fsSL https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/install.sh | sh - -set -eu - -# Release artifacts live in a Scaleway Object Storage bucket we control. -# Override BASE_URL for a mirror or local testing; DODECA_VERSION pins a -# specific version (otherwise the `latest` pointer is read). -BASE_URL="${{DODECA_BASE_URL:-{base_url}}}" - -# Detect platform (only linux-x64 and macos-arm64 are supported) -detect_platform() {{ - local os arch - - os="$(uname -s)" - arch="$(uname -m)" - - case "$os" in - Linux) - case "$arch" in - x86_64) echo "x86_64-unknown-linux-gnu" ;; - *) echo "Unsupported Linux architecture: $arch (only x86_64 supported)" >&2; exit 1 ;; - esac - ;; - Darwin) - case "$arch" in - arm64) echo "aarch64-apple-darwin" ;; - *) echo "Unsupported macOS architecture: $arch (only arm64 supported)" >&2; exit 1 ;; - esac - ;; - *) - echo "Unsupported OS: $os" >&2 - exit 1 - ;; - esac -}} - -# Read the `latest` pointer (a text file holding the newest version string). -get_latest_version() {{ - curl -fsSL "$BASE_URL/latest" -}} - -main() {{ - local platform version archive_name url install_dir - - platform="$(detect_platform)" - version="${{DODECA_VERSION:-$(get_latest_version)}}" - archive_name="dodeca-$platform.tar.xz" - url="$BASE_URL/$version/$archive_name" - install_dir="${{DODECA_INSTALL_DIR:-$HOME/.cargo/bin}}" - - echo "Installing dodeca $version for $platform..." - echo " Archive: $url" - echo " Install dir: $install_dir" - - # Create install directory - mkdir -p "$install_dir" - - # Download and extract - local tmpdir - tmpdir="$(mktemp -d)" - trap "rm -rf '$tmpdir'" EXIT - - echo "Downloading..." - curl -fsSL "$url" -o "$tmpdir/archive.tar.xz" - - echo "Extracting..." - tar -xJf "$tmpdir/archive.tar.xz" -C "$tmpdir" - - echo "Installing..." - # Copy main binary - cp "$tmpdir/ddc" "$install_dir/" - chmod +x "$install_dir/ddc" - - # Copy browser JS/WASM assets beside the binary. ddc also supports - # DODECA_ASSETS_DIR for custom package layouts. - if [ -d "$tmpdir/dodeca-assets" ]; then - rm -rf "$install_dir/dodeca-assets" - cp -R "$tmpdir/dodeca-assets" "$install_dir/" - else - echo "warning: archive did not contain dodeca-assets; browser search and DevTools assets will be missing" >&2 - echo "warning: run 'ddc assets' after installation for lookup paths and repair commands" >&2 - fi - - echo "" - echo "Successfully installed dodeca to $install_dir/ddc" - echo "" - - # Check if install_dir is in PATH - case ":$PATH:" in - *":$install_dir:"*) ;; - *) - echo "NOTE: $install_dir is not in your PATH." - echo "Add this to your shell profile:" - echo "" - echo " export PATH=\"\$PATH:$install_dir\"" - echo "" - ;; - esac -}} - -main "$@" -"##, - base_url = RELEASE_BASE_URL - ) -} - -/// Generate the PowerShell installer script content. -pub fn generate_powershell_installer() -> String { - format!( - r##"# Installer for dodeca -# Usage: powershell -ExecutionPolicy Bypass -c "irm https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/install.ps1 | iex" - -$ErrorActionPreference = 'Stop' - -# Release artifacts live in a Scaleway Object Storage bucket we control. -# Override with $env:DODECA_BASE_URL; $env:DODECA_VERSION pins a version. -$BaseUrl = if ($env:DODECA_BASE_URL) {{ $env:DODECA_BASE_URL }} else {{ "{base_url}" }} - -function Get-Architecture {{ - $arch = [System.Environment]::Is64BitOperatingSystem - if ($arch) {{ - return "x86_64" - }} else {{ - Write-Error "Only x64 architecture is supported on Windows" - exit 1 - }} -}} - -function Get-LatestVersion {{ - try {{ - return (Invoke-RestMethod -Uri "$BaseUrl/latest").Trim() - }} catch {{ - Write-Error "Failed to get latest version: $_" - exit 1 - }} -}} - -function Main {{ - $arch = Get-Architecture - $version = if ($env:DODECA_VERSION) {{ $env:DODECA_VERSION }} else {{ Get-LatestVersion }} - $archiveName = "dodeca-x86_64-pc-windows-msvc.zip" - $url = "$BaseUrl/$version/$archiveName" - - # Default install location - $installDir = if ($env:DODECA_INSTALL_DIR) {{ - $env:DODECA_INSTALL_DIR - }} else {{ - Join-Path $env:LOCALAPPDATA "dodeca" - }} - - Write-Host "Installing dodeca $version for Windows x64..." - Write-Host " Archive: $url" - Write-Host " Install dir: $installDir" - - # Create install directory - New-Item -ItemType Directory -Force -Path $installDir | Out-Null - - # Download and extract - $tempDir = Join-Path $env:TEMP "dodeca-install-$(New-Guid)" - New-Item -ItemType Directory -Force -Path $tempDir | Out-Null - - try {{ - Write-Host "Downloading..." - $archivePath = Join-Path $tempDir "archive.zip" - Invoke-WebRequest -Uri $url -OutFile $archivePath - - Write-Host "Extracting..." - Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force - - Write-Host "Installing..." - Copy-Item -Path (Join-Path $tempDir "ddc.exe") -Destination $installDir -Force - - $assetsDir = Join-Path $tempDir "dodeca-assets" - if (Test-Path $assetsDir) {{ - $installedAssets = Join-Path $installDir "dodeca-assets" - if (Test-Path $installedAssets) {{ - Remove-Item -Recurse -Force $installedAssets - }} - Copy-Item -Path $assetsDir -Destination $installDir -Recurse -Force - }} else {{ - Write-Warning "Archive did not contain dodeca-assets; browser search and DevTools assets will be missing." - Write-Warning "Run 'ddc assets' after installation for lookup paths and repair commands." - }} - - Write-Host "" - Write-Host "Successfully installed dodeca to $installDir\ddc.exe" - Write-Host "" - - # Check if install_dir is in PATH - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if ($userPath -notlike "*$installDir*") {{ - Write-Host "NOTE: $installDir is not in your PATH." - Write-Host "Adding $installDir to your user PATH..." - - try {{ - $newPath = if ($userPath) {{ "$userPath;$installDir" }} else {{ $installDir }} - [Environment]::SetEnvironmentVariable("Path", $newPath, "User") - Write-Host "Successfully added to PATH. You may need to restart your terminal." - }} catch {{ - Write-Host "Failed to add to PATH automatically. Please add it manually:" - Write-Host " 1. Open System Properties > Environment Variables" - Write-Host " 2. Add '$installDir' to your user PATH variable" - }} - Write-Host "" - }} - }} finally {{ - # Cleanup - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - }} -}} - -Main -"##, - base_url = RELEASE_BASE_URL - ) -} - -/// Helper to serialize a workflow to YAML with the generated header. -fn workflow_to_yaml(workflow: &Workflow) -> Result { - let yaml = facet_yaml::to_string(workflow) - .map_err(|e| eyre::eyre!("failed to serialize workflow: {}", e))?; - let yaml = yaml - .lines() - .map(str::trim_end) - .collect::>() - .join("\n"); - - Ok(format!("{GENERATED_HEADER}{yaml}\n")) -} - -/// Check or write a generated file. -fn check_or_write(path: &Utf8Path, content: &str, check: bool) -> Result<()> { - if check { - let existing = fs_err::read_to_string(path) - .map_err(|e| eyre::eyre!("failed to read {}: {}", path, e))?; - - if existing != content { - return Err(eyre::eyre!( - "{} is out of date. Run `cargo xtask ci-github` and `cargo xtask ci-forgejo` to update.", - path.file_name().unwrap_or("file") - )); - } - println!("{} is up to date.", path.file_name().unwrap_or("file")); - } else { - fs_err::create_dir_all(path.parent().unwrap()) - .map_err(|e| eyre::eyre!("failed to create directory: {}", e))?; - - fs_err::write(path, content).map_err(|e| eyre::eyre!("failed to write {}: {}", path, e))?; - - println!("Generated: {}", path); - } - Ok(()) -} - -/// Generate GitHub Actions workflow and installer script. -pub fn generate_github(repo_root: &Utf8Path, check: bool) -> Result<()> { - // Generate GitHub Actions workflow - let github_workflows_dir = repo_root.join(CiPlatform::GitHub.workflows_dir()); - let github_ci_workflow = build_ci_workflow(CiPlatform::GitHub, repo_root); - let github_ci_yaml = workflow_to_yaml(&github_ci_workflow)?; - check_or_write(&github_workflows_dir.join("ci.yml"), &github_ci_yaml, check)?; - - // Generate installer script (for GitHub releases) - let installer_content = generate_installer_script(); - check_or_write(&repo_root.join("install.sh"), &installer_content, check)?; - - Ok(()) -} - -/// Generate Forgejo Actions workflow. -pub fn generate_forgejo(repo_root: &Utf8Path, check: bool) -> Result<()> { - // Generate Forgejo Actions workflow (completely separate implementation) - let forgejo_workflows_dir = repo_root.join(CiPlatform::Forgejo.workflows_dir()); - let forgejo_ci_workflow = build_forgejo_workflow(repo_root); - let forgejo_ci_yaml = workflow_to_yaml(&forgejo_ci_workflow)?; - check_or_write( - &forgejo_workflows_dir.join("ci.yml"), - &forgejo_ci_yaml, - check, - )?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use camino::Utf8Path; - - #[test] - fn github_release_needs_linux_integration_and_tag_only_packages() { - let workflow = build_ci_workflow(CiPlatform::GitHub, Utf8Path::new(".")); - - let release = workflow.jobs.get("release").expect("release job exists"); - assert_eq!( - release.needs.as_ref(), - Some(&vec![ - "integration-linux-x64".to_string(), - "package-linux-x64".to_string(), - "package-macos-arm64".to_string(), - "package-windows-x64".to_string(), - ]) - ); - } - - #[test] - fn github_package_jobs_are_tag_only_on_intended_runners() { - let workflow = build_ci_workflow(CiPlatform::GitHub, Utf8Path::new(".")); - - let linux_pkg = workflow - .jobs - .get("package-linux-x64") - .expect("linux package job"); - assert_eq!( - linux_pkg.runs_on.0, - vec!["bearcove-ubuntu-24.04".to_string()] - ); - assert_eq!( - linux_pkg.if_condition.as_deref(), - Some("startsWith(github.ref, 'refs/tags/')") - ); - - let mac_pkg = workflow - .jobs - .get("package-macos-arm64") - .expect("mac package job"); - assert_eq!( - mac_pkg.runs_on.0, - vec!["blacksmith-12vcpu-macos-15".to_string()] - ); - assert_eq!( - mac_pkg.if_condition.as_deref(), - Some("startsWith(github.ref, 'refs/tags/')") - ); - - let win_pkg = workflow - .jobs - .get("package-windows-x64") - .expect("windows package job"); - assert_eq!( - win_pkg.runs_on.0, - vec!["blacksmith-8vcpu-windows-2025".to_string()] - ); - assert_eq!( - win_pkg.if_condition.as_deref(), - Some("startsWith(github.ref, 'refs/tags/')") - ); - } - - #[test] - fn github_every_commit_checks_on_free_runners() { - let workflow = build_ci_workflow(CiPlatform::GitHub, Utf8Path::new(".")); - - let mac = workflow.jobs.get("check-macos").expect("mac check job"); - assert_eq!(mac.runs_on.0, vec!["macos-15".to_string()]); - assert!(mac.if_condition.is_none()); - assert_eq!( - mac.steps - .iter() - .find(|s| s.name.as_deref() == Some("Check macOS")) - .and_then(|s| s.run.as_deref()), - Some("cargo check --workspace --all-targets") - ); - let mac_cache = mac - .steps - .iter() - .find(|s| s.name.as_deref() == Some("Rust cache")) - .expect("mac check rust cache step"); - assert_eq!(mac_cache.uses.as_deref(), Some("Swatinem/rust-cache@v2")); - assert_eq!( - mac_cache.with.as_ref().and_then(|i| i.get("cache-targets")), - Some(&"true".to_string()) - ); - assert_eq!( - mac_cache.with.as_ref().and_then(|i| i.get("cache-bin")), - Some(&"false".to_string()) - ); - - let win = workflow - .jobs - .get("check-windows") - .expect("windows check job"); - assert_eq!(win.runs_on.0, vec!["windows-latest".to_string()]); - assert!(win.if_condition.is_none()); - let win_rust_toolchain = win - .steps - .iter() - .find(|s| s.name.as_deref() == Some("Install Rust")) - .and_then(|s| s.with.as_ref().and_then(|i| i.get("toolchain"))); - assert_eq!(win_rust_toolchain, Some(&"stable".to_string())); - assert_eq!( - win.steps - .iter() - .find(|s| s.name.as_deref() == Some("Check Windows")) - .and_then(|s| s.run.as_deref()), - Some("cargo check --workspace --all-targets") - ); - let win_cache = win - .steps - .iter() - .find(|s| s.name.as_deref() == Some("Rust cache")) - .expect("windows check rust cache step"); - assert_eq!(win_cache.uses.as_deref(), Some("Swatinem/rust-cache@v2")); - assert_eq!( - win_cache.with.as_ref().and_then(|i| i.get("cache-targets")), - Some(&"true".to_string()) - ); - } - - #[test] - fn github_no_macos_build_or_integration_on_commit() { - let workflow = build_ci_workflow(CiPlatform::GitHub, Utf8Path::new(".")); - assert!(!workflow.jobs.contains_key("build-ddc-macos-arm64")); - assert!(!workflow.jobs.contains_key("integration-macos-arm64")); - assert!(!workflow.jobs.contains_key("release-macos-arm64")); - } - - #[test] - fn github_linux_build_does_not_archive_on_commit() { - let workflow = build_ci_workflow(CiPlatform::GitHub, Utf8Path::new(".")); - let build = workflow - .jobs - .get("build-ddc-linux-x64") - .expect("linux build job"); - // No "Assemble archive" step on the every-commit build. - assert!( - !build - .steps - .iter() - .any(|s| s.name.as_deref() == Some("Assemble archive")) - ); - } - - #[test] - fn github_all_rust_install_steps_pin_stable() { - let workflow = build_ci_workflow(CiPlatform::GitHub, Utf8Path::new(".")); - for (id, job) in &workflow.jobs { - for step in &job.steps { - if step.name.as_deref() == Some("Install Rust") { - let toolchain = step - .with - .as_ref() - .and_then(|i| i.get("toolchain")) - .unwrap_or_else(|| { - panic!("job {id} Install Rust step missing toolchain input") - }); - assert_eq!(toolchain, "stable", "job {id} should pin stable toolchain"); - } - } - } - } -} diff --git a/xtask/src/installer.rs b/xtask/src/installer.rs new file mode 100644 index 000000000..2119b6f67 --- /dev/null +++ b/xtask/src/installer.rs @@ -0,0 +1,242 @@ +//! Installer script generation for dodeca release artifacts. +//! +//! Both the POSIX shell (`install.sh`) and PowerShell (`install.ps1`) installers +//! are generated from `RELEASE_BASE_URL` here, so the default artifact location +//! stays a single source of truth. (The GitHub/Forgejo workflow generator that +//! used to live alongside these was removed; the release pipeline now runs on +//! Buildkite/private infrastructure.) + +/// Base URL for release artifacts: the `bearcove-dist` Scaleway Object Storage +/// bucket (fr-par), under the `dodeca/releases/` prefix. dodeca is a bearcove +/// project, so it has its own bucket separate from Vixen's. Each release is +/// `//dodeca-.tar.xz`; `/latest` is a text file +/// holding the newest version string. Overridable at install time via +/// `DODECA_BASE_URL` (mirrors / testing). +/// +/// Objects are uploaded public-read and served from the same S3 API endpoint +/// (`s3.fr-par.scw.cloud`); see scripts/publish-release.sh. +pub const RELEASE_BASE_URL: &str = "https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/releases"; + +/// Generate the shell installer script content. +pub fn generate_installer_script() -> String { + // No Rust interpolation needed — everything below is shell. The default + // base URL is injected once so the generator stays the single source. + format!( + r##"#!/bin/sh +# Installer for dodeca +# Usage: curl -fsSL https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/install.sh | sh + +set -eu + +# Release artifacts live in a Scaleway Object Storage bucket we control. +# Override BASE_URL for a mirror or local testing; DODECA_VERSION pins a +# specific version (otherwise the `latest` pointer is read). +BASE_URL="${{DODECA_BASE_URL:-{base_url}}}" + +# Detect platform (only linux-x64 and macos-arm64 are supported) +detect_platform() {{ + local os arch + + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) + case "$arch" in + x86_64) echo "x86_64-unknown-linux-gnu" ;; + *) echo "Unsupported Linux architecture: $arch (only x86_64 supported)" >&2; exit 1 ;; + esac + ;; + Darwin) + case "$arch" in + arm64) echo "aarch64-apple-darwin" ;; + *) echo "Unsupported macOS architecture: $arch (only arm64 supported)" >&2; exit 1 ;; + esac + ;; + *) + echo "Unsupported OS: $os" >&2 + exit 1 + ;; + esac +}} + +# Read the `latest` pointer (a text file holding the newest version string). +get_latest_version() {{ + curl -fsSL "$BASE_URL/latest" +}} + +main() {{ + local platform version archive_name url install_dir + + platform="$(detect_platform)" + version="${{DODECA_VERSION:-$(get_latest_version)}}" + archive_name="dodeca-$platform.tar.xz" + url="$BASE_URL/$version/$archive_name" + install_dir="${{DODECA_INSTALL_DIR:-$HOME/.cargo/bin}}" + + echo "Installing dodeca $version for $platform..." + echo " Archive: $url" + echo " Install dir: $install_dir" + + # Create install directory + mkdir -p "$install_dir" + + # Download and extract + local tmpdir + tmpdir="$(mktemp -d)" + trap "rm -rf '$tmpdir'" EXIT + + echo "Downloading..." + curl -fsSL "$url" -o "$tmpdir/archive.tar.xz" + + echo "Extracting..." + tar -xJf "$tmpdir/archive.tar.xz" -C "$tmpdir" + + echo "Installing..." + # Copy main binary + cp "$tmpdir/ddc" "$install_dir/" + chmod +x "$install_dir/ddc" + + # Copy browser JS/WASM assets beside the binary. ddc also supports + # DODECA_ASSETS_DIR for custom package layouts. + if [ -d "$tmpdir/dodeca-assets" ]; then + rm -rf "$install_dir/dodeca-assets" + cp -R "$tmpdir/dodeca-assets" "$install_dir/" + else + echo "warning: archive did not contain dodeca-assets; browser search and DevTools assets will be missing" >&2 + echo "warning: run 'ddc assets' after installation for lookup paths and repair commands" >&2 + fi + + echo "" + echo "Successfully installed dodeca to $install_dir/ddc" + echo "" + + # Check if install_dir is in PATH + case ":$PATH:" in + *":$install_dir:"*) ;; + *) + echo "NOTE: $install_dir is not in your PATH." + echo "Add this to your shell profile:" + echo "" + echo " export PATH=\"\$PATH:$install_dir\"" + echo "" + ;; + esac +}} + +main "$@" +"##, + base_url = RELEASE_BASE_URL + ) +} + +/// Generate the PowerShell installer script content. +pub fn generate_powershell_installer() -> String { + format!( + r##"# Installer for dodeca +# Usage: powershell -ExecutionPolicy Bypass -c "irm https://bearcove-dist.s3.fr-par.scw.cloud/dodeca/install.ps1 | iex" + +$ErrorActionPreference = 'Stop' + +# Release artifacts live in a Scaleway Object Storage bucket we control. +# Override with $env:DODECA_BASE_URL; $env:DODECA_VERSION pins a version. +$BaseUrl = if ($env:DODECA_BASE_URL) {{ $env:DODECA_BASE_URL }} else {{ "{base_url}" }} + +function Get-Architecture {{ + $arch = [System.Environment]::Is64BitOperatingSystem + if ($arch) {{ + return "x86_64" + }} else {{ + Write-Error "Only x64 architecture is supported on Windows" + exit 1 + }} +}} + +function Get-LatestVersion {{ + try {{ + return (Invoke-RestMethod -Uri "$BaseUrl/latest").Trim() + }} catch {{ + Write-Error "Failed to get latest version: $_" + exit 1 + }} +}} + +function Main {{ + $arch = Get-Architecture + $version = if ($env:DODECA_VERSION) {{ $env:DODECA_VERSION }} else {{ Get-LatestVersion }} + $archiveName = "dodeca-x86_64-pc-windows-msvc.zip" + $url = "$BaseUrl/$version/$archiveName" + + # Default install location + $installDir = if ($env:DODECA_INSTALL_DIR) {{ + $env:DODECA_INSTALL_DIR + }} else {{ + Join-Path $env:LOCALAPPDATA "dodeca" + }} + + Write-Host "Installing dodeca $version for Windows x64..." + Write-Host " Archive: $url" + Write-Host " Install dir: $installDir" + + # Create install directory + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + + # Download and extract + $tempDir = Join-Path $env:TEMP "dodeca-install-$(New-Guid)" + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + + try {{ + Write-Host "Downloading..." + $archivePath = Join-Path $tempDir "archive.zip" + Invoke-WebRequest -Uri $url -OutFile $archivePath + + Write-Host "Extracting..." + Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force + + Write-Host "Installing..." + Copy-Item -Path (Join-Path $tempDir "ddc.exe") -Destination $installDir -Force + + $assetsDir = Join-Path $tempDir "dodeca-assets" + if (Test-Path $assetsDir) {{ + $installedAssets = Join-Path $installDir "dodeca-assets" + if (Test-Path $installedAssets) {{ + Remove-Item -Recurse -Force $installedAssets + }} + Copy-Item -Path $assetsDir -Destination $installDir -Recurse -Force + }} else {{ + Write-Warning "Archive did not contain dodeca-assets; browser search and DevTools assets will be missing." + Write-Warning "Run 'ddc assets' after installation for lookup paths and repair commands." + }} + + Write-Host "" + Write-Host "Successfully installed dodeca to $installDir\ddc.exe" + Write-Host "" + + # Check if install_dir is in PATH + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($userPath -notlike "*$installDir*") {{ + Write-Host "NOTE: $installDir is not in your PATH." + Write-Host "Adding $installDir to your user PATH..." + + try {{ + $newPath = if ($userPath) {{ "$userPath;$installDir" }} else {{ $installDir }} + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + Write-Host "Successfully added to PATH. You may need to restart your terminal." + }} catch {{ + Write-Host "Failed to add to PATH automatically. Please add it manually:" + Write-Host " 1. Open System Properties > Environment Variables" + Write-Host " 2. Add '$installDir' to your user PATH variable" + }} + Write-Host "" + }} + }} finally {{ + # Cleanup + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + }} +}} + +Main +"##, + base_url = RELEASE_BASE_URL + ) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cedef9fea..5c866986d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,6 +1,6 @@ //! Build tasks for dodeca -mod ci; +mod installer; use std::env; use std::fs; @@ -8,7 +8,6 @@ use std::path::PathBuf; use std::process::{Command, ExitCode}; use std::time::SystemTime; -use camino::Utf8PathBuf; use facet::Facet; use figue as args; use owo_colors::OwoColorize; @@ -37,20 +36,12 @@ struct RunArgs { #[derive(Facet, Debug)] struct InstallArgs {} -/// CI GitHub command - generate GitHub workflow +/// Generate shell installer (install.sh) #[derive(Facet, Debug)] -struct CiGithubArgs { - /// Check that generated files are up to date (don't write) - #[facet(args::named)] - check: bool, -} - -/// CI Forgejo command - generate Forgejo workflow -#[derive(Facet, Debug)] -struct CiForgejoArgs { - /// Check that generated files are up to date (don't write) - #[facet(args::named)] - check: bool, +struct GenerateInstallerArgs { + /// Output path for the installer script + #[facet(args::positional)] + output_path: String, } /// Generate PowerShell installer @@ -90,10 +81,8 @@ enum XtaskCommand { Run(RunArgs), /// Build release & install to ~/.cargo/bin Install(InstallArgs), - /// Generate GitHub workflow - CiGithub(CiGithubArgs), - /// Generate Forgejo workflow - CiForgejo(CiForgejoArgs), + /// Generate shell installer (install.sh) + GenerateInstaller(GenerateInstallerArgs), /// Generate PowerShell installer GeneratePs1Installer(GeneratePs1InstallerArgs), /// Run integration tests @@ -149,32 +138,17 @@ fn main() -> ExitCode { } ExitCode::SUCCESS } - XtaskCommand::CiGithub(args) => { - let repo_root = - Utf8PathBuf::from_path_buf(env::current_dir().expect("get current directory")) - .expect("current dir is valid UTF-8"); - match ci::generate_github(&repo_root, args.check) { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("{}: {e}", "error".red().bold()); - ExitCode::FAILURE - } - } - } - XtaskCommand::CiForgejo(args) => { - let repo_root = - Utf8PathBuf::from_path_buf(env::current_dir().expect("get current directory")) - .expect("current dir is valid UTF-8"); - match ci::generate_forgejo(&repo_root, args.check) { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("{}: {e}", "error".red().bold()); - ExitCode::FAILURE - } + XtaskCommand::GenerateInstaller(args) => { + let content = installer::generate_installer_script(); + if let Err(e) = fs::write(&args.output_path, content) { + eprintln!("{}: writing installer: {e}", "error".red().bold()); + return ExitCode::FAILURE; } + eprintln!("Generated installer: {}", args.output_path); + ExitCode::SUCCESS } XtaskCommand::GeneratePs1Installer(args) => { - let content = ci::generate_powershell_installer(); + let content = installer::generate_powershell_installer(); if let Err(e) = fs::write(&args.output_path, content) { eprintln!( "{}: writing PowerShell installer: {e}", @@ -364,8 +338,8 @@ fn install_dev() -> bool { // Build and stage the browser JS/WASM assets (search runtime, DevTools // runtime + UI) beside the installed `ddc`, so `ddc build` works out of - // the box. This mirrors the release pipeline in xtask/src/ci.rs, which - // installs wasm-pack/pnpm, runs scripts/build-browser-assets.sh and then + // the box. This mirrors the release packaging pipeline, which installs + // wasm-pack/pnpm, runs scripts/build-browser-assets.sh and then // scripts/stage-browser-assets.sh into a `dodeca-assets` directory. let assets_dst = cargo_bin.join("dodeca-assets"); if !build_browser_assets() { @@ -416,8 +390,8 @@ fn install_dev() -> bool { /// Build the browser JS/WASM assets (search runtime, DevTools runtime + UI). /// -/// Mirrors the release pipeline in `xtask/src/ci.rs`: installs `wasm-pack` and -/// `pnpm` when missing, then runs `scripts/build-browser-assets.sh`. +/// Mirrors the release packaging pipeline: installs `wasm-pack` and `pnpm` +/// when missing, then runs `scripts/build-browser-assets.sh`. fn build_browser_assets() -> bool { eprintln!("Building browser assets (WASM + DevTools UI)...");