diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2844312b..aed032d1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,81 +2,11 @@ name: PR Pipeline on: pull_request: branches: [master] +permissions: + contents: read jobs: build-deploy: - runs-on: self-hosted - env: - SCROLL_REGISTRY_PR_NAMESPACE: druid-team-experimental - SCROLL_REGISTRY_ENDPOINT: ${{ secrets.SCROLL_REGISTRY_ENDPOINT }} - SCROLL_REGISTRY_API_KEY: ${{ secrets.SCROLL_REGISTRY_API_KEY }} - SCROLL_REGISTRY_API_SECRET: ${{ secrets.SCROLL_REGISTRY_API_SECRET }} - SCROLL_REGISTRY_BUCKET: ${{ secrets.SCROLL_REGISTRY_BUCKET_STAGING }} - DRUID_CLI_VERSION: v0.1.257 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - run: apt update && apt install -y make - - name: Build scroll tree - env: - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - run: | - if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then - registry_host="${SCROLL_REGISTRY_HOST#http://}" - registry_host="${registry_host#https://}" - registry_host="${registry_host%%/*}" - export DRUID_COLDSTARTER_IMAGE="${registry_host}/${SCROLL_REGISTRY_PR_NAMESPACE}/druid:stable-pr${{ github.event.pull_request.number }}" - fi - make build-tree - - name: Get registry binary - if: github.event.pull_request.head.repo.full_name == github.repository - uses: robinraju/release-downloader@v1.7 - with: - repository: "highcard-dev/druid-cli" - tag: ${{ env.DRUID_CLI_VERSION }} - fileName: "druid" - token: ${{ secrets.GO_REPO_TOKEN }} - - name: Install druid - if: github.event.pull_request.head.repo.full_name == github.repository - run: | - chmod +x druid - mv druid /usr/local/bin/druid - - name: Validate all scrolls - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Test bounded push parallelism - run: bash ./scripts/tests/push-parallel.test.sh - - name: Login to registry - id: registry_login - continue-on-error: true - if: github.event.pull_request.head.repo.full_name == github.repository - run: druid login --host ${{ secrets.SCROLL_REGISTRY_HOST }} --user '${{ secrets.SCROLL_REGISTRY_USER }}' --password ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - - name: Check scroll changes - id: scroll-changes - run: | - set -euo pipefail - git fetch origin "${{ github.base_ref }}" --depth=1 - if git diff --quiet "origin/${{ github.base_ref }}"..HEAD -- scrolls; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No scroll source changes; skipping preview push." - else - echo "changed=true" >> "$GITHUB_OUTPUT" - echo "Scroll source changed; publishing preview tags." - fi - - name: Push experimental PR tags - if: github.event.pull_request.head.repo.full_name == github.repository && steps.scroll-changes.outputs.changed == 'true' && steps.registry_login.outcome == 'success' - env: - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_NAMESPACE: druid-team-experimental - SCROLL_REGISTRY_RUNTIME_NAMESPACE: druid-team - SCROLL_TAG_SUFFIX: -pr${{ github.event.pull_request.number }} - SCROLL_PUSH_CATEGORIES: "0" - run: bash ./scripts/push.sh + uses: ./.github/workflows/scrolls.yml + secrets: inherit + with: + release: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea52735d..56032a76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,47 +2,13 @@ name: Release Changed Pipeline on: workflow_dispatch: push: - branches: - - master - tags: - - v* + branches: [master] + tags: [v*] +permissions: + contents: read jobs: build-deploy: - runs-on: self-hosted - env: - SCROLL_REGISTRY_ENDPOINT: ${{ secrets.SCROLL_REGISTRY_ENDPOINT }} - SCROLL_REGISTRY_API_KEY: ${{ secrets.SCROLL_REGISTRY_API_KEY }} - SCROLL_REGISTRY_API_SECRET: ${{ secrets.SCROLL_REGISTRY_API_SECRET }} - SCROLL_REGISTRY_BUCKET: ${{ secrets.SCROLL_REGISTRY_BUCKET_STAGING }} - SCROLL_REGISTRY_HOST: ${{ secrets.SCROLL_REGISTRY_HOST }} - SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} - SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} - DRUID_CLI_VERSION: v0.1.257 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: ui/package-lock.json - - name: Build and test Scroll UI - working-directory: ui - run: npm ci && npm test && npm run type-check && npm run build - - name: Get registry binary - uses: robinraju/release-downloader@v1.7 - with: - repository: "highcard-dev/druid-cli" - tag: ${{ env.DRUID_CLI_VERSION }} - fileName: "druid" - token: ${{ secrets.GO_REPO_TOKEN }} - - run: chmod +x druid - - name: Install druid - run: mv druid /usr/local/bin/druid - - name: druid version - run: druid version - - run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh - - name: Push release scrolls - run: bash ./scripts/push.sh + uses: ./.github/workflows/scrolls.yml + secrets: inherit + with: + release: true diff --git a/.github/workflows/scrolls.yml b/.github/workflows/scrolls.yml new file mode 100644 index 00000000..400d9340 --- /dev/null +++ b/.github/workflows/scrolls.yml @@ -0,0 +1,78 @@ +name: Build and push Scrolls +on: + workflow_call: + inputs: + release: + description: Update release tags instead of PR preview tags. + required: true + type: boolean +permissions: + contents: read + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 360 + concurrency: + group: scrolls-${{ inputs.release && 'release' || format('pr-{0}', github.event.pull_request.number) }} + cancel-in-progress: false + env: + DRUID_CLI_VERSION: ${{ vars.DRUID_CLI_VERSION || 'v0.1.257' }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version: "1.24.7" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build and test Scroll UI + working-directory: ui + run: npm ci && npm test && npm run type-check && npm run build + - name: Build scroll tree + if: github.event_name == 'pull_request' + run: make build-tree + - name: Download released CLI + uses: robinraju/release-downloader@v1.7 + with: + repository: highcard-dev/druid-cli + tag: ${{ env.DRUID_CLI_VERSION }} + fileName: druid + token: ${{ github.token }} + - name: Install CLI + run: | + mkdir -p "$RUNNER_TEMP/scroll-cli" + chmod +x druid + mv druid "$RUNNER_TEMP/scroll-cli/druid" + echo "$RUNNER_TEMP/scroll-cli" >> "$GITHUB_PATH" + - name: Validate catalog and UI + run: ./scripts/validate_all_scrolls.sh && bash ./scripts/validate_ui_coverage.sh + - name: Test catalog publication + run: bash ./scripts/tests/push-parallel.test.sh + - name: Push registry updates + # Fork PRs validate without receiving registry credentials. + if: >- + (inputs.release && github.event_name != 'pull_request') || + (!inputs.release && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + env: + SCROLL_REGISTRY_HOST: ${{ vars.SCROLL_REGISTRY_HOST || 'artifacts.druid.gg' }} + SCROLL_REGISTRY_NAMESPACE: ${{ inputs.release && (vars.SCROLL_REGISTRY_NAMESPACE || 'druid-team') || (vars.SCROLL_PREVIEW_NAMESPACE || 'druid-team-experimental') }} + SCROLL_REGISTRY_RUNTIME_NAMESPACE: ${{ vars.SCROLL_REGISTRY_RUNTIME_NAMESPACE || vars.SCROLL_REGISTRY_NAMESPACE || 'druid-team' }} + SCROLL_REGISTRY_USER: ${{ secrets.SCROLL_REGISTRY_USER }} + SCROLL_REGISTRY_PASSWORD: ${{ secrets.SCROLL_REGISTRY_PASSWORD }} + SCROLL_PUSH_CATEGORIES: ${{ inputs.release && '1' || '0' }} + DRUID_SCROLL_RUNTIME_IMAGE: ${{ vars.DRUID_SCROLL_RUNTIME_IMAGE }} + DRUID_SCROLL_STEAMCMD_IMAGE: ${{ vars.DRUID_SCROLL_STEAMCMD_IMAGE }} + run: | + set -euo pipefail + : "${SCROLL_REGISTRY_USER:?Set the SCROLL_REGISTRY_USER secret}" + : "${SCROLL_REGISTRY_PASSWORD:?Set the SCROLL_REGISTRY_PASSWORD secret}" + if [[ "${{ inputs.release }}" != "true" ]]; then + export SCROLL_TAG_SUFFIX="-pr${{ github.event.pull_request.number }}" + fi + bash ./scripts/push.sh diff --git a/docs/registry-publication.md b/docs/registry-publication.md new file mode 100644 index 00000000..a73feddf --- /dev/null +++ b/docs/registry-publication.md @@ -0,0 +1,54 @@ +# Registry updates + +CI builds the Scroll UI, validates the catalog, logs into the registry and runs +`scripts/push.sh`. That shared script is the explicit catalog used by both CI +and local seeding. Release runs update the existing repositories and tags, +with category metadata pushed before Scroll artifacts. + +There are no Druid account credentials, Core/Auth API calls, publication +snapshots, catalog registration calls or visibility changes. These repositories +are already public and registered; CI only updates their OCI artifacts. + +## Configuration + +Only two GitHub Actions secrets are required: + +- `SCROLL_REGISTRY_USER` +- `SCROLL_REGISTRY_PASSWORD` + +Optional repository variables override the existing defaults: + +| Variable | Default | +| --- | --- | +| `SCROLL_REGISTRY_HOST` | `artifacts.druid.gg` | +| `SCROLL_REGISTRY_NAMESPACE` | `druid-team` | +| `SCROLL_PREVIEW_NAMESPACE` | `druid-team-experimental` | +| `SCROLL_REGISTRY_RUNTIME_NAMESPACE` | Release namespace | +| `DRUID_CLI_VERSION` | `v0.1.257` | + +`DRUID_CLI_VERSION` selects the released CLI and default runtime image tag. +`DRUID_SCROLL_RUNTIME_IMAGE` and `DRUID_SCROLL_STEAMCMD_IMAGE` can override the +runtime image references independently. CI downloads the CLI release binary; +it does not check out or build CLI source. + +Same-repository PRs push preview tags suffixed with `-pr` into the +preview namespace without changing release tags or category metadata. Fork +PRs only build and validate. Runtime image references stay in the release +namespace. Both reusable-workflow callers inherit the repository secrets. + +## Verification and dependencies + +Run `bash scripts/tests/push-parallel.test.sh` and +`bash scripts/validate_all_scrolls.sh` locally. Use `SCROLL_PUSH_DRY_RUN=1 bash +scripts/push.sh` to inspect the catalog without registry writes. The push script +retains its serial default, bounded parallel option and category barrier. + +The existing Go tree generator, Scroll validator and UI staging helper remain: +they generate or validate actual artifact content. The CI-only Go API publisher +and workflow parser are removed. + +The Scroll UI still needs published Druid UI packages exporting +`ensureFileService`, `addAndRunCommand` and `stopCommand`, and a matching lockfile. +Local unpublished packages do not prove that a clean CI build will pass. +Registry updates themselves do not depend on Core publication APIs or a Druid +account. diff --git a/scripts/push.sh b/scripts/push.sh index ef5a9e1f..6ea02b6b 100755 --- a/scripts/push.sh +++ b/scripts/push.sh @@ -18,7 +18,6 @@ SCROLL_PUSH_CATEGORIES="${SCROLL_PUSH_CATEGORIES:-1}" SCROLL_PUSH_ARTIFACTS="${SCROLL_PUSH_ARTIFACTS:-1}" SCROLL_PUSH_JOBS="${SCROLL_PUSH_JOBS:-1}" SCROLL_PUSH_UI="${SCROLL_PUSH_UI:-1}" - if [[ ! "$SCROLL_PUSH_JOBS" =~ ^[1-9][0-9]*$ ]]; then echo "SCROLL_PUSH_JOBS must be a positive integer (got: $SCROLL_PUSH_JOBS)" >&2 exit 2 @@ -78,6 +77,15 @@ login_if_configured() { fi } +rewrite_registry_reference() { + local value="$1" + if [[ "$value" == "$runtime_image" || "$value" == "$steamcmd_image" ]]; then + printf '%s' "$value" + return + fi + printf '%s' "${value//artifacts.druid.gg\/druid-team/$registry_prefix}" +} + run() { local -a command=("$@") local index @@ -101,7 +109,11 @@ run() { for index in "${!command[@]}"; do command[$index]="${command[$index]//artifacts.druid.gg\/druid-team\/druid:v0.1.257-steamcmd/$steamcmd_image}" command[$index]="${command[$index]//artifacts.druid.gg\/druid-team\/druid:v0.1.257/$runtime_image}" - command[$index]="${command[$index]//artifacts.druid.gg\/druid-team/$registry_prefix}" + if [[ "${command[2]:-}" == "category" ]]; then + command[$index]="${command[$index]//artifacts.druid.gg\/druid-team/$registry_prefix}" + else + command[$index]="$(rewrite_registry_reference "${command[$index]}")" + fi done if [[ "${command[0]:-}" == "druid" ]]; then command[0]="$DRUID_BIN" diff --git a/scripts/stage-scroll-ui/main.go b/scripts/stage-scroll-ui/main.go index d59b6d58..8fff78c5 100644 --- a/scripts/stage-scroll-ui/main.go +++ b/scripts/stage-scroll-ui/main.go @@ -339,6 +339,22 @@ func stage(source, destination, bundle string) error { if err := cp.Copy(source, destination); err != nil { return fmt.Errorf("copy Scroll: %w", err) } + // Version directories inherit family presentation. Keep it inside the staged + // release so the lifecycle never depends on mutable category tags afterward. + if _, err := os.Stat(filepath.Join(destination, ".meta")); os.IsNotExist(err) { + for parent := filepath.Dir(source); parent != "." && parent != filepath.Dir(parent); parent = filepath.Dir(parent) { + metadata := filepath.Join(parent, ".meta") + if info, err := os.Stat(metadata); err == nil && info.IsDir() { + if err := cp.Copy(metadata, filepath.Join(destination, ".meta")); err != nil { + return fmt.Errorf("copy presentation: %w", err) + } + break + } + if filepath.Base(parent) == "scrolls" { + break + } + } + } scrollPath := filepath.Join(destination, "scroll.yaml") scrollBytes, err := os.ReadFile(scrollPath) diff --git a/scripts/stage-scroll-ui/main_test.go b/scripts/stage-scroll-ui/main_test.go index 90704054..dd0a02f3 100644 --- a/scripts/stage-scroll-ui/main_test.go +++ b/scripts/stage-scroll-ui/main_test.go @@ -16,6 +16,13 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err := os.MkdirAll(source, 0755); err != nil { t.Fatal(err) } + metadata := filepath.Join(filepath.Dir(source), ".meta") + if err := os.MkdirAll(metadata, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(metadata, "en-US.md"), []byte("---\nname: Paper\n---\nPresentation\n"), 0644); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: test\ndesc: test\nversion: 0.0.1\napp_version: 1.21.7\ncommands: {}\n"), 0644); err != nil { t.Fatal(err) } @@ -40,6 +47,9 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if err := stage(source, destination, bundle); err != nil { t.Fatal(err) } + if _, err := os.Stat(filepath.Join(destination, ".meta", "en-US.md")); err != nil { + t.Fatal("family presentation missing from release", err) + } stagedYAML, err := os.ReadFile(filepath.Join(destination, "scroll.yaml")) if err != nil { diff --git a/scripts/tests/push-parallel.test.sh b/scripts/tests/push-parallel.test.sh index cf856e91..d8713b79 100644 --- a/scripts/tests/push-parallel.test.sh +++ b/scripts/tests/push-parallel.test.sh @@ -40,9 +40,30 @@ artifact_dry_run="$( SCROLL_PUSH_ARTIFACTS=1 \ bash "$push_script" )" +[[ "$artifact_dry_run" == *"artifacts.druid.gg/druid-team/scroll-minecraft-spigot:1.17"* ]] || \ + fail "release pushes must update the existing repositories and tags" +[[ "$artifact_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257"* ]] || \ + fail "release pushes must preserve runtime image references" +[[ "$category_dry_run" == *"artifacts.druid.gg/druid-team/scroll-minecraft-spigot"* ]] || \ + fail "category metadata must retain the configured registry namespace" expected_categories="$(printf '%s\n' "$category_dry_run" | grep -Ec '^"?druid"? push category ')" expected_artifacts="$(printf '%s\n' "$artifact_dry_run" | grep -Ec '^"?druid"? push ')" +preview_dry_run="$( + SCROLL_PUSH_DRY_RUN=1 \ + SCROLL_PUSH_CATEGORIES=0 \ + SCROLL_REGISTRY_NAMESPACE=druid-team-experimental \ + SCROLL_REGISTRY_RUNTIME_NAMESPACE=druid-team \ + SCROLL_TAG_SUFFIX=-pr123 \ + bash "$push_script" +)" +[[ "$preview_dry_run" == *"artifacts.druid.gg/druid-team-experimental/scroll-minecraft-spigot:1.17-pr123"* ]] || \ + fail "preview pushes must use the preview namespace and tag suffix" +[[ "$preview_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257"* ]] || \ + fail "preview pushes must leave runtime images in the release namespace" +[[ "$preview_dry_run" == *"-i artifacts.druid.gg/druid-team/druid:v0.1.257-steamcmd"* ]] || \ + fail "preview pushes must preserve the SteamCMD runtime image" + serial_dry_run="$(SCROLL_PUSH_DRY_RUN=1 SCROLL_PUSH_JOBS=1 bash "$push_script")" parallel_dry_run="$(SCROLL_PUSH_DRY_RUN=1 SCROLL_PUSH_JOBS=4 bash "$push_script")" assert_equals "$serial_dry_run" "$parallel_dry_run" \ @@ -73,6 +94,34 @@ reset_state assert_equals "1" "$(<"$tmp_dir/state/max")" \ "the default must preserve serial push behavior" +reset_state +DRUID_BIN="$fake_druid" \ +FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ +FAKE_DRUID_SLEEP=0 \ +SCROLL_REGISTRY_USER=test-publisher \ +SCROLL_REGISTRY_PASSWORD=test-password \ +SCROLL_PUSH_ARTIFACTS=0 \ + bash "$push_script" >"$tmp_dir/login.out" +grep -q '^start artifact login --host artifacts.druid.gg --user test-publisher --password test-password$' "$tmp_dir/state/events.log" || \ + fail "configured registry credentials must be passed to druid login" +assert_equals "start artifact login" "$(head -n 1 "$tmp_dir/state/events.log" | cut -d ' ' -f1-3)" \ + "registry login must happen before any push" + +reset_state +if DRUID_BIN="$fake_druid" \ + FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ + FAKE_DRUID_SLEEP=0 \ + FAKE_DRUID_FAIL_REF=--host \ + SCROLL_REGISTRY_USER=test-publisher \ + SCROLL_REGISTRY_PASSWORD=test-password \ + SCROLL_PUSH_ARTIFACTS=0 \ + bash "$push_script" >"$tmp_dir/login-failure.out" 2>"$tmp_dir/login-failure.err"; then + fail "a failed registry login must make push.sh fail" +fi +if grep -q '^start .* push ' "$tmp_dir/state/events.log"; then + fail "no artifacts or categories may be pushed after a failed login" +fi + reset_state DRUID_BIN="$fake_druid" \ FAKE_DRUID_STATE_DIR="$tmp_dir/state" \ @@ -90,7 +139,6 @@ assert_equals "$expected_categories" "$actual_categories" \ "parallel mode must invoke every category push exactly once" assert_equals "$expected_artifacts" "$actual_artifacts" \ "parallel mode must invoke every artifact push exactly once" - last_category_end="$(grep -n '^end category ' "$tmp_dir/state/events.log" | tail -n 1 | cut -d: -f1)" first_artifact_start="$(grep -m 1 -n '^start artifact ' "$tmp_dir/state/events.log" | cut -d: -f1)" ((last_category_end < first_artifact_start)) || \ diff --git a/scripts/validate-release-workflow/main.go b/scripts/validate-release-workflow/main.go deleted file mode 100644 index d0796d05..00000000 --- a/scripts/validate-release-workflow/main.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - "regexp" - "strconv" - "strings" -) - -var ( - forbiddenReleaseValues = []string{ - "v0.1.248", - "highcard/druid", - "stable-nix", - "latest-nix", - "-nix-steamcmd", - } - portOverrideNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) - pzserverRequiredPorts = map[string]string{ - "main": "/udp", - "main2": "/udp", - } -) - -func main() { - path := "scripts/push.sh" - if len(os.Args) > 1 { - path = os.Args[1] - } - if err := validateReleaseWorkflow(path); err != nil { - fmt.Fprintln(os.Stderr, "Error:", err) - os.Exit(1) - } -} - -func validateReleaseWorkflow(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - lines := strings.Split(string(data), "\n") - var failures []string - foundPZServer := false - - for index, line := range lines { - lineNumber := index + 1 - for _, forbidden := range forbiddenReleaseValues { - if strings.Contains(line, forbidden) { - failures = append(failures, fmt.Sprintf("%s:%d contains forbidden value %q", path, lineNumber, forbidden)) - } - } - if !isArtifactPushLine(line) { - continue - } - fields := strings.Fields(line) - ports, err := validatePortOverrides(fields) - if err != nil { - failures = append(failures, fmt.Sprintf("%s:%d %v", path, lineNumber, err)) - } - if strings.Contains(line, "scroll-lgsm:pzserver") { - foundPZServer = true - for name, expected := range pzserverRequiredPorts { - if actual := ports[name]; actual != expected { - failures = append(failures, fmt.Sprintf("%s:%d pzserver requires -p %s=%s, got %q", path, lineNumber, name, expected, actual)) - } - } - } - } - - if !foundPZServer { - failures = append(failures, fmt.Sprintf("%s: missing pzserver release push", path)) - } - if len(failures) > 0 { - return errors.New(strings.Join(failures, "\n")) - } - return nil -} - -func isArtifactPushLine(line string) bool { - fields := strings.Fields(line) - for i := 0; i+2 < len(fields); i++ { - if fields[i] == "druid" && fields[i+1] == "push" && fields[i+2] != "category" { - return true - } - } - return false -} - -func validatePortOverrides(fields []string) (map[string]string, error) { - ports := make(map[string]string) - var failures []string - - for i, field := range fields { - if field != "-p" && field != "--port" { - continue - } - if i+1 >= len(fields) { - failures = append(failures, fmt.Sprintf("%s is missing an override", field)) - continue - } - override := fields[i+1] - name, value, hasValue := strings.Cut(override, "=") - if !portOverrideNamePattern.MatchString(name) { - failures = append(failures, fmt.Sprintf("invalid %s override %q; invalid port name", field, override)) - continue - } - if !hasValue { - value = "" - } - portText := value - protocol := "" - if strings.Contains(value, "/") { - parts := strings.Split(value, "/") - if len(parts) != 2 || parts[1] == "" { - failures = append(failures, fmt.Sprintf("invalid %s override %q; expected name=port or name=port/protocol", field, override)) - continue - } - portText = parts[0] - protocol = parts[1] - switch protocol { - case "tcp", "udp", "http", "https": - default: - failures = append(failures, fmt.Sprintf("invalid %s override %q; unsupported protocol", field, override)) - continue - } - } - if portText == "" || portText == "0" { - if protocol == "http" || protocol == "https" { - failures = append(failures, fmt.Sprintf("invalid %s override %q; dynamic HTTP ports are not supported", field, override)) - continue - } - ports[name] = "0" - if protocol != "" { - ports[name] = "/" + protocol - } - continue - } - port, err := strconv.Atoi(portText) - if err != nil || port < 1 || port > 65535 { - failures = append(failures, fmt.Sprintf("invalid %s override %q; port must be omitted or 1-65535", field, override)) - continue - } - ports[name] = portText - if protocol != "" { - ports[name] += "/" + protocol - } - } - - if len(failures) > 0 { - return ports, errors.New(strings.Join(failures, "; ")) - } - return ports, nil -} diff --git a/scripts/validate-release-workflow/main_test.go b/scripts/validate-release-workflow/main_test.go deleted file mode 100644 index b183a246..00000000 --- a/scripts/validate-release-workflow/main_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestValidatePortOverridesAcceptsConcretePorts(t *testing.T) { - fields := strings.Fields("druid push repo:tag ./scroll -p main=25565 -p query=27015/udp -p battle-eye=2304/udp -p webpanel=8080") - - ports, err := validatePortOverrides(fields) - if err != nil { - t.Fatal(err) - } - if ports["main"] != "25565" || ports["query"] != "27015/udp" || ports["battle-eye"] != "2304/udp" || ports["webpanel"] != "8080" { - t.Fatalf("ports = %#v", ports) - } -} - -func TestValidatePortOverridesAcceptsDynamicPorts(t *testing.T) { - for _, value := range []string{ - "main", - "main=", - "main=/udp", - "main=0", - "main=0/udp", - } { - fields := []string{"druid", "push", "repo:tag", "./scroll", "-p", value} - if _, err := validatePortOverrides(fields); err != nil { - t.Fatalf("%s failed validation: %v", value, err) - } - } -} - -func TestValidatePortOverridesRejectsInvalidPorts(t *testing.T) { - for _, value := range []string{ - "main=65536", - "main=/", - "main=/sctp", - "web=/http", - "web=0/https", - "main=123/udp/extra", - "1main=123", - } { - fields := []string{"druid", "push", "repo:tag", "./scroll", "-p", value} - if _, err := validatePortOverrides(fields); err == nil { - t.Fatalf("%s passed validation, want failure", value) - } - } -} - -func TestIsArtifactPushLineSkipsCategories(t *testing.T) { - if isArtifactPushLine("druid push category artifacts.druid.gg/foo bar ./meta") { - t.Fatal("category push should not be treated as artifact push") - } - if !isArtifactPushLine("druid push artifacts.druid.gg/foo:tag ./scroll -p main=1") { - t.Fatal("artifact push was not detected") - } -} diff --git a/scripts/validate_all_scrolls.sh b/scripts/validate_all_scrolls.sh index 4e5f6693..d7bc73da 100755 --- a/scripts/validate_all_scrolls.sh +++ b/scripts/validate_all_scrolls.sh @@ -2,8 +2,6 @@ set -euo pipefail -go run ./scripts/validate-release-workflow - if command -v druid >/dev/null 2>&1 && druid validate --help >/dev/null 2>&1; then while IFS= read -r file; do dir="${file%/scroll.yaml}" diff --git a/ui/package-lock.json b/ui/package-lock.json index f2cb3d65..bad0eddc 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -587,9 +587,9 @@ } }, "node_modules/@druid-ui/build": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@druid-ui/build/-/build-3.0.0.tgz", - "integrity": "sha512-Tr7ctnLNpjny+btPAFbVdsDjH90GWLgsfswk8iJyBRsvW09ZV26DY8uHx1oD+yyzxuuVLqMkbhz2btMWrBdVAg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@druid-ui/build/-/build-3.0.1.tgz", + "integrity": "sha512-ZNwEE7hPmotzf/BRoOGdsF580GV8i1FuY1NbgTIaTxcLnEVbm+hMdAh8GeOTfBG3aR+NUS2j56xgDyfDLTL5mw==", "dev": true, "dependencies": { "@bytecodealliance/componentize-js": "^0.19.3", @@ -603,9 +603,9 @@ } }, "node_modules/@druid-ui/component": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@druid-ui/component/-/component-3.0.0.tgz", - "integrity": "sha512-4Hz/6M0ssAnNXHepKnMZLrjBFrXjMsZclvsBlNIacAk1ta23zOpgr/AJpWGPzDCZhqhiUM5WULRMnxNQXkZzLA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@druid-ui/component/-/component-3.0.1.tgz", + "integrity": "sha512-c6mWsenI+TKhFxN3WEDJLVyFx5GeO7xVIt7S+cAtVgzmT5eFr2AqBArbkOro4zlIBDetoO/5z1A+SX0LUSArOg==", "dev": true, "dependencies": { "@druid-ui/build": "*", @@ -624,9 +624,9 @@ } }, "node_modules/@druid-ui/plattform": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@druid-ui/plattform/-/plattform-3.0.0.tgz", - "integrity": "sha512-PJtjiuAxYJYHZAkDOooTr1PrGMLz0Ucd1W2LBY+PdCQSf3IXkLF1Lu8unRzgJLE0AQhHSe2+JlnQllWk1hcoDg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@druid-ui/plattform/-/plattform-3.0.1.tgz", + "integrity": "sha512-A3hs60cYgmF+/VVYTQPr8o1V5xLVwls8XqXe6pzuepw5W4iUulva9m+K0tCLkR5zozC7VLxAScIlx0QIvaTbFw==", "dev": true, "dependencies": { "@druid-ui/component": "*" diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 00a2c6e0..4c486d95 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -2,12 +2,13 @@ import { createConfigEditorComponent, fingerprint, withMissingFileFallback, - type FileGateway, } from "./config-editor/index.js"; import { + ensureFileService, loadFileFromDeployment, saveFileToDeployment, } from "@druid-ui/plattform"; +import { createFileServiceGateway } from "./config-editor/file-service-gateway.js"; const requireString = (value: unknown, operation: string): string => { if (typeof value !== "string") { @@ -16,15 +17,18 @@ const requireString = (value: unknown, operation: string): string => { return value; }; -const gateway = withMissingFileFallback({ - async load(path) { +const gateway = withMissingFileFallback(createFileServiceGateway({ + ensureFileService, + async loadFile(path) { return requireString(await loadFileFromDeployment(path), "loadFileFromDeployment"); }, - async save(path, content) { + async saveFile(path, content) { await saveFileToDeployment(path, content); - return { status: "saved", fingerprint: await fingerprint(content) }; }, -} satisfies FileGateway); + async fingerprint(content) { + return fingerprint(content); + }, +})); export const component = createConfigEditorComponent({ manifestPath: ".druid/config-editor.manifest.json", diff --git a/ui/src/config-editor/file-service-gateway.test.ts b/ui/src/config-editor/file-service-gateway.test.ts new file mode 100644 index 00000000..9b02a705 --- /dev/null +++ b/ui/src/config-editor/file-service-gateway.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFileServiceGateway } from "./file-service-gateway.js"; + +const createService = () => ({ + ensureFileService: vi.fn<() => Promise>().mockResolvedValue(undefined), + loadFile: vi.fn<(path: string) => Promise>().mockResolvedValue("content"), + saveFile: vi.fn<(path: string, content: string) => Promise>().mockResolvedValue(undefined), + fingerprint: vi.fn<(content: string) => Promise>().mockResolvedValue("fingerprint"), +}); + +describe("createFileServiceGateway", () => { + it("ensures the file service before loading and saving files", async () => { + const service = createService(); + const gateway = createFileServiceGateway(service); + + await expect(gateway.load("server.properties")).resolves.toBe("content"); + await expect(gateway.save("server.properties", "content", "ignored")).resolves.toEqual({ + status: "saved", + fingerprint: "fingerprint", + }); + + expect(service.ensureFileService).toHaveBeenCalledOnce(); + expect(service.ensureFileService).toHaveBeenCalledWith(false); + expect(service.loadFile).toHaveBeenCalledWith("server.properties"); + expect(service.saveFile).toHaveBeenCalledWith("server.properties", "content"); + }); + + it("shares startup between concurrent file operations", async () => { + let resolveStartup: (() => void) | undefined; + const service = createService(); + service.ensureFileService.mockImplementation( + () => + new Promise((resolve) => { + resolveStartup = resolve; + }) + ); + const gateway = createFileServiceGateway(service); + + const load = gateway.load("server.properties"); + const save = gateway.save("server.properties", "content", "ignored"); + await vi.waitFor(() => expect(service.ensureFileService).toHaveBeenCalledOnce()); + resolveStartup?.(); + + await Promise.all([load, save]); + expect(service.ensureFileService).toHaveBeenCalledWith(false); + }); + + it("retries startup after a failure", async () => { + const service = createService(); + service.ensureFileService.mockRejectedValueOnce(new Error("unavailable")); + const gateway = createFileServiceGateway(service); + + await expect(gateway.load("server.properties")).rejects.toThrow("unavailable"); + await expect(gateway.load("server.properties")).resolves.toBe("content"); + + expect(service.ensureFileService).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/config-editor/file-service-gateway.ts b/ui/src/config-editor/file-service-gateway.ts new file mode 100644 index 00000000..04dc2b89 --- /dev/null +++ b/ui/src/config-editor/file-service-gateway.ts @@ -0,0 +1,42 @@ +import type { FileGateway, SaveResult } from "./gateway.js"; + +export interface DeploymentFileService { + ensureFileService(watch?: boolean): Promise; + loadFile(path: string): Promise; + saveFile(path: string, content: string): Promise; + fingerprint(content: string): Promise; +} + +/** + * Ensures WebDAV once before using deployment files. A failed startup is not + * cached, allowing the next user action to retry it. + */ +export const createFileServiceGateway = (service: DeploymentFileService): FileGateway => { + let ready: Promise | undefined; + + const ensureReady = (): Promise => { + if (ready) return ready; + + let startup: Promise; + startup = Promise.resolve() + .then(() => service.ensureFileService(false)) + .catch((error: unknown) => { + if (ready === startup) ready = undefined; + throw error; + }); + ready = startup; + return startup; + }; + + return { + async load(path) { + await ensureReady(); + return service.loadFile(path); + }, + async save(path, content): Promise { + await ensureReady(); + await service.saveFile(path, content); + return { status: "saved", fingerprint: await service.fingerprint(content) }; + }, + }; +};