From 638fc514e9b233d2708f1375ee273563ff7878af Mon Sep 17 00:00:00 2001 From: Kasinathan Date: Sun, 11 Oct 2026 12:24:47 +0530 Subject: [PATCH] Fixes --- .github/workflows/sync-release.yml | 259 +++++++++++++---- .github/workflows/website-ci.yml | 51 ++++ README.md | 27 +- public/sitemap.xml | 15 - src/App.tsx | 6 - src/components/common/ArchitectureDiagram.tsx | 261 ------------------ src/components/common/Footer.tsx | 3 - .../downloads/DownloadHero.test.tsx | 33 ++- src/config/channels.test.ts | 35 ++- src/config/channels.ts | 55 ++-- src/config/navigation.ts | 3 - src/config/roadmap.ts | 127 --------- src/pages/ArchitecturePage.tsx | 165 ----------- src/pages/ChangelogPage.tsx | 13 +- src/pages/DownloadsPage.test.tsx | 28 +- src/pages/HomePage.tsx | 4 +- src/pages/RoadmapPage.tsx | 160 ----------- src/pages/ShulkerPage.tsx | 194 ------------- src/services/downloadSelection.test.ts | 26 +- src/services/downloadSelection.ts | 4 +- src/services/github.test.ts | 154 +++++++++++ src/services/github.ts | 49 ++-- src/services/releaseValidation.test.ts | 79 +++--- src/services/releaseValidation.ts | 2 +- src/services/searchIndex.ts | 28 +- src/services/workflowConsistency.test.ts | 54 +++- src/types/release.ts | 5 +- src/website-cleanup.test.ts | 79 ++++++ 28 files changed, 746 insertions(+), 1173 deletions(-) create mode 100644 .github/workflows/website-ci.yml delete mode 100644 src/components/common/ArchitectureDiagram.tsx delete mode 100644 src/config/roadmap.ts delete mode 100644 src/pages/ArchitecturePage.tsx delete mode 100644 src/pages/RoadmapPage.tsx delete mode 100644 src/pages/ShulkerPage.tsx create mode 100644 src/services/github.test.ts create mode 100644 src/website-cleanup.test.ts diff --git a/.github/workflows/sync-release.yml b/.github/workflows/sync-release.yml index 8b3cf35..ec457a8 100644 --- a/.github/workflows/sync-release.yml +++ b/.github/workflows/sync-release.yml @@ -1,15 +1,37 @@ name: Sync release metadata # Receives the `veltis-release` repository event from VeltisMC/Veltis after a -# successful build and release, validates the payload, and writes the channel's -# metadata into `public/releases/.json`. Vercel redeploys on the push. +# successful build and release, validates the payload, and stages the channel's +# metadata as a pull request against `main`. # -# The build repository owns the metadata; this workflow never originates it and -# never invents a value. If the payload is malformed the job fails loudly and the -# site is left unchanged. +# `main` is a protected branch: no workflow push may bypass its rules. This +# workflow therefore never pushes to `main`. It writes to a dedicated, +# per-channel `release-metadata/` branch, opens (or reuses) a PR, and +# lets the protected-branch machinery — required checks, Vercel preview and +# production protections, and review — decide when the metadata lands. It never +# disables protection, never uses a privileged token, never force-pushes, and +# never auto-merges. # -# Payload shape (a repository_dispatch allows only ten top-level `client_payload` -# properties, so the build facts and the durable location are grouped): +# Verified commits: the metadata commit is created with the GraphQL +# `createCommitOnBranch` mutation — GitHub's supported mechanism for a GitHub +# App (the workflow's own GITHUB_TOKEN) to author a commit that GitHub signs +# with its own key. The commit shows as "Verified" without GPG, a PAT, or a +# secret. +# +# Idempotency: entries are de-duplicated by commit, a commit is only created +# when the channel file actually changes, and a PR is only created when none +# exists for the branch. Re-dispatching an already-recorded build is a no-op +# that duplicates neither history nor PRs. +# +# Required checks: a PR created with GITHUB_TOKEN does not re-trigger +# `pull_request` workflows, so this workflow explicitly dispatches the website +# CI workflow on the metadata branch; that dispatch always runs and reports the +# required checks on the branch head. Vercel deploys a preview for the branch +# itself and production on merge to `main`. +# +# Payload shape (a repository_dispatch allows only ten top-level +# `client_payload` properties, so the build facts and the durable location are +# grouped): # # client_payload.metadata = the build's build-metadata.json # channel, version, minecraftVersion, commit, shortCommit, artifact, @@ -17,21 +39,25 @@ name: Sync release metadata # client_payload.release = the published GitHub Release # downloadUrl, releaseUrl, publishedAt, size # -# `artifact` is the human release title (`Veltis 26.3 nightly 3077e84.jar`). +# `artifact` is the human release title (`Veltis 26.3 pre-alpha 3077e84.jar`). # GitHub normalizes every uploaded asset name, folding the spaces to dots, so the -# stored/downloaded file is `assetName` -# (`Veltis.26.3.nightly.3077e84.jar`). Both are carried, and both are checked -# against each other and against the download URL. +# stored/downloaded file is `assetName` (`Veltis.26.3.pre-alpha.3077e84.jar`). +# Both are carried, and both are checked against each other and against the +# download URL. on: repository_dispatch: types: [veltis-release] -# Only the repository's own contents are written, with the workflow's token. -# A push made with GITHUB_TOKEN does not re-trigger Actions, so this cannot loop. +# The workflow's own token and nothing else. Writing the metadata branch and the +# PR wants `contents` and `pull-requests`; dispatching CI on the branch wants +# `actions`. No personal access token, no secret, and no way to bypass `main`'s +# protection. permissions: contents: write + pull-requests: write + actions: write -# Serialize updates so two releases cannot race to write the same channel file. +# Serialize updates so two releases cannot race to compute the same channel file. concurrency: group: sync-release-metadata cancel-in-progress: false @@ -44,8 +70,11 @@ jobs: sync: runs-on: ubuntu-latest steps: - - name: Checkout the website + - name: Checkout main uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 - name: Validate the release payload id: payload @@ -61,9 +90,10 @@ jobs: # One named boolean per invariant, so a failure names the exact field # rather than only saying the payload is wrong. Every fact the download # page renders is checked; a payload is rejected outright rather than - # partially trusted. No secret is ever printed. + # partially trusted. No secret is ever printed. Retired channel + # identifiers are not in the supported set and are rejected here. CHECKS=$(jq -c --arg repo "$RELEASE_REPOSITORY" ' - def supported: IN("pre-alpha", "alpha", "beta", "release", "stable", "nightly"); + def supported: IN("pre-alpha", "alpha", "beta", "release", "stable"); # GitHub replaces every run of characters outside [A-Za-z0-9_+@-] with # a dot, collapses runs of dots and trims the edges. This one rule is @@ -125,7 +155,7 @@ jobs: ' "$FILE") if ! echo "$CHECKS" | jq -e 'to_entries | all(.[]; .value == true)' > /dev/null; then - echo "::error::the release payload failed validation; the site was not updated" + echo "::error::the release payload failed validation; no metadata was written and no PR was opened" echo "--- failed checks ---" echo "$CHECKS" | jq -r 'to_entries[] | select(.value != true) | " - \(.key)"' echo "--- received payload ---" @@ -140,71 +170,206 @@ jobs: echo "artifact=$(jq -r .metadata.artifact "$FILE")" } >> "$GITHUB_OUTPUT" - - name: Update the channel metadata + - name: Stage the channel metadata + id: stage env: CHANNEL: ${{ steps.payload.outputs.channel }} + SHORT: ${{ steps.payload.outputs.shortCommit }} + COMMIT: ${{ steps.payload.outputs.commit }} run: | set -euo pipefail - mkdir -p public/releases + BRANCH="release-metadata/$CHANNEL" FILE="public/releases/$CHANNEL.json" + mkdir -p public/releases - PREV='{"history":[]}' - if [ -f "$FILE" ]; then PREV=$(cat "$FILE"); fi + # Current state: the branch's copy of the file (if the branch already + # exists), and main's copy. Both feed the merged history so a fresh + # branch never drops an entry that already landed on main. + MAIN_FILE="$RUNNER_TEMP/main.json" + if [ -f "$FILE" ]; then cp "$FILE" "$MAIN_FILE"; else printf '{"history":[]}' > "$MAIN_FILE"; fi + + BRANCH_FILE="$RUNNER_TEMP/branch.json" + BRANCH_OID="" + if git rev-parse -q --verify "refs/remotes/origin/$BRANCH" > /dev/null; then + BRANCH_OID=$(git rev-parse "refs/remotes/origin/$BRANCH") + if git cat-file -e "refs/remotes/origin/$BRANCH:$FILE" 2>/dev/null; then + git show "refs/remotes/origin/$BRANCH:$FILE" > "$BRANCH_FILE" + else + printf '{"history":[]}' > "$BRANCH_FILE" + fi + else + printf '{"history":[]}' > "$BRANCH_FILE" + fi + + # Already merged into main? Then there is nothing to stage or PR. + if jq -e --arg c "$COMMIT" \ + '([.latest, (.history // [])[]] | map(.commit) | index($c)) != null' \ + "$MAIN_FILE" > /dev/null 2>&1; then + echo "::notice::commit $SHORT is already recorded on main; nothing to sync." + echo "noop=true" >> "$GITHUB_OUTPUT" + exit 0 + fi - # Only the incoming channel's file is touched, so publishing a nightly - # never rewrites stable history. The new build is newest; older entries - # are kept, de-duplicated by commit (a duplicate dispatch therefore does - # not duplicate history), sorted newest first and capped. Pruning always - # keeps the newest entry first, so a previous valid release is never - # dropped in favour of an older one. + # The only file touched is this channel's. Newest first, de-duplicated + # by commit (a duplicate dispatch therefore cannot duplicate history), + # capped, and merged with any entries already on the branch or main. ENTRY=$(jq -c '.metadata + .release' "$RUNNER_TEMP/payload.json") jq -n \ --argjson incoming "$ENTRY" \ - --argjson prev "$PREV" \ + --argjson main "$(cat "$MAIN_FILE")" \ + --argjson branch "$(cat "$BRANCH_FILE")" \ --arg channel "$CHANNEL" \ '{ channel: $channel, latest: $incoming, history: ( - [$incoming] + [($prev.latest // empty)] + ($prev.history // []) + [$incoming] + + ([($main.latest // empty)] + ($main.history // [])) + + ([($branch.latest // empty)] + ($branch.history // [])) | unique_by(.commit) | sort_by(.publishedAt) | reverse | .[0:20] ) - }' > "$FILE.tmp" - mv "$FILE.tmp" "$FILE" + }' > "$RUNNER_TEMP/$CHANNEL.json" - # Re-read what was written and confirm it still parses as expected. + # Re-read what was computed and confirm it still parses before anything + # is committed; a hand-edited or malformed file must stop the run here. jq -e --arg channel "$CHANNEL" ' - (.channel | IN("pre-alpha", "alpha", "beta", "release", "stable", "nightly")) + (.channel | IN("pre-alpha", "alpha", "beta", "release", "stable")) and (.channel == $channel) and (.latest.commit | test("^[0-9a-f]{40}$")) and (.latest.downloadUrl | test("^https://github.com/")) - and (.history | length > 0) - ' "$FILE" > /dev/null || { - echo "::error::the written $FILE is invalid; refusing to commit" - cat "$FILE" + and (.history | type == "array") + ' "$RUNNER_TEMP/$CHANNEL.json" > /dev/null || { + echo "::error::the staged $FILE is invalid; refusing to commit" + cat "$RUNNER_TEMP/$CHANNEL.json" exit 1 } - echo "Updated $FILE" + # Idempotency: no commit when the branch already carries this file. + if [ -n "$BRANCH_OID" ] && cmp -s "$RUNNER_TEMP/$CHANNEL.json" "$BRANCH_FILE"; then + echo "::notice::$BRANCH already carries this release; no new commit." + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "noop=true" >> "$GITHUB_OUTPUT" + else + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "branch_oid=$BRANCH_OID" >> "$GITHUB_OUTPUT" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create or reuse the metadata branch + if: steps.stage.outputs.changed == 'true' + env: + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + BRANCH="${{ steps.stage.outputs.branch }}" + OID="${{ steps.stage.outputs.branch_oid }}" + if [ -z "$OID" ]; then + MAIN_HEAD=$(git rev-parse HEAD) + gh api -X POST "repos/{owner}/{repo}/git/refs" \ + -f "ref=refs/heads/$BRANCH" -f "sha=$MAIN_HEAD" + echo "Created branch $BRANCH at $MAIN_HEAD" + else + echo "Reused existing branch $BRANCH at $OID" + fi - - name: Commit and push + - name: Commit the metadata with a verified signature + if: steps.stage.outputs.changed == 'true' env: CHANNEL: ${{ steps.payload.outputs.channel }} SHORT: ${{ steps.payload.outputs.shortCommit }} ARTIFACT: ${{ steps.payload.outputs.artifact }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail - git config user.name "VeltisMC Release Bot" - git config user.email "release-bot@users.noreply.github.com" + BRANCH="${{ steps.stage.outputs.branch }}" + HEAD_OID="${{ steps.stage.outputs.branch_oid }}" + if [ -z "$HEAD_OID" ]; then HEAD_OID=$(git rev-parse HEAD); fi + FILE_PATH="public/releases/$CHANNEL.json" + BASE64=$(base64 -w0 "$RUNNER_TEMP/$CHANNEL.json") + + # createCommitOnBranch signs the commit with GitHub's own key, so the + # metadata commit is "Verified" without GPG or a secret. The mutation + # creates the commit and advances the branch to it; no remote push is + # ever performed, so main is never touched and a forced update is + # impossible. + INPUT=$(printf '{"branch":{"repositoryNameWithOwner":"%s","branchName":"%s"},"message":{"headline":"release: %s %s (%s)","body":"Automated staging of the Veltis %s build published at %s."},"fileChanges":{"additions":[{"path":"%s","contents":"%s"}]},"expectedHeadOid":"%s"}' \ + "${{ github.repository }}" "$BRANCH" "$CHANNEL" "$SHORT" "$ARTIFACT" "$CHANNEL" "$SHORT" "$FILE_PATH" "$BASE64" "$HEAD_OID") - git add public/releases - if git diff --cached --quiet; then - echo "This release is already recorded; nothing to commit." + NEW_OID=$(gh api graphql \ + -f query='mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }' \ + -F "input=$INPUT" \ + --jq '.data.createCommitOnBranch.commit.oid') || { + echo "::error::createCommitOnBranch failed for $BRANCH (expected head was $HEAD_OID)." + echo "::error::If the branch moved since the dispatch, re-dispatch the same release; the workflow is idempotent." + exit 1 + } + echo "::notice::Created verified commit $NEW_OID on $BRANCH" + echo "head_oid=$NEW_OID" >> "$GITHUB_OUTPUT" + + - name: Open or reuse the pull request + if: steps.stage.outputs.branch != '' + env: + CHANNEL: ${{ steps.payload.outputs.channel }} + SHORT: ${{ steps.payload.outputs.shortCommit }} + ARTIFACT: ${{ steps.payload.outputs.artifact }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + BRANCH="release-metadata/$CHANNEL" + + PR_NUMBER=$(gh pr list --head "$BRANCH" --base main --state open --json number -q '.[0].number' 2>/dev/null || true) + if [ -n "$PR_NUMBER" ]; then + echo "::notice::PR #$PR_NUMBER for $BRANCH already open; nothing to create." + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" exit 0 fi - git commit -m "release: $CHANNEL $SHORT ($ARTIFACT)" - git push + BODY=$(printf \ + 'Automated release-metadata sync from **%s**.\n\n- Artifact: `%s`\n- Commit: `%s`\n\nThis PR was opened by the release-sync workflow. It never pushes to `main` directly and never auto-merges: it lands through the normal protected-branch rules (required checks, review, and Vercel deployment protection).' \ + "$RELEASE_REPOSITORY" "$ARTIFACT" "$SHORT") + + # Creating a PR does not trigger pull_request workflows (a GITHUB_TOKEN + # event), so the CI dispatch below supplies the required checks. + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "release: $CHANNEL $SHORT ($ARTIFACT)" \ + --body "$BODY" > /tmp/pr-url.txt + echo "::notice::Opened $(cat /tmp/pr-url.txt)" + PR_NUMBER=$(gh pr list --head "$BRANCH" --base main --state open --json number -q '.[0].number') + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Run the required checks on the metadata branch + if: steps.stage.outputs.branch != '' && ! cancelled() + env: + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + BRANCH="${{ steps.stage.outputs.branch }}" + # A PR created by GITHUB_TOKEN does not fire pull_request workflows, so + # this explicit dispatch — the one event a GITHUB_TOKEN run may always + # trigger — runs the website CI on the branch head and reports the + # check the branch protection expects. It is never auto-merged. + gh workflow run website-ci.yml --ref "$BRANCH" || { + echo "::error::could not dispatch website-ci.yml on $BRANCH; the branch will not satisfy required checks until it runs" + exit 1 + } + echo "::notice::Dispatched website-ci.yml on $BRANCH" + + - name: Summarize + env: + CHANNEL: ${{ steps.payload.outputs.channel }} + SHORT: ${{ steps.payload.outputs.shortCommit }} + run: | + if [ "${{ steps.stage.outputs.changed }}" = "true" ]; then + echo "Staged release metadata for $CHANNEL $SHORT on release-metadata/$CHANNEL." + echo "A PR is open against main. Required checks and the protected-branch rules decide when it merges." + echo "Merging triggers Vercel's production deployment; the branch already has a Vercel preview." + elif [ "${{ steps.stage.outputs.noop }}" = "true" ]; then + echo "No change needed for $CHANNEL $SHORT; it is already recorded." + else + echo "No change needed for $CHANNEL $SHORT; the metadata branch already carries it." + fi \ No newline at end of file diff --git a/.github/workflows/website-ci.yml b/.github/workflows/website-ci.yml new file mode 100644 index 0000000..37b2ea9 --- /dev/null +++ b/.github/workflows/website-ci.yml @@ -0,0 +1,51 @@ +name: Website CI + +# The website's own CI gate. It runs on every pull request against `main` and on +# every push to `main`, and it can be dispatched explicitly on any branch. +# +# The release-sync workflow creates its metadata PR with GITHUB_TOKEN, which does +# not re-trigger `pull_request` runs, so it calls this workflow with +# `workflow_dispatch` on the metadata branch; that dispatch always runs and +# reports the CI check on the branch head, satisfying the protected-branch +# required check. +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: website-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm test \ No newline at end of file diff --git a/README.md b/README.md index ececb3f..2678184 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,12 @@ Official website and developer portal for **Veltis**, the optimization-focused M ## Features - **Developer-Centric Visual Identity:** Deep charcoal/near-black theme with electric violet accents, geometric typography, and zero SaaS fluff. -- **PaperMC-Inspired Downloads:** Compact build browser with Minecraft version tabs, Stable/Beta/Nightly channel toggle, direct JAR downloads, and SHA-256 copy action. -- **Release Metadata Sync:** A `veltis-release` repository event from `VeltisMC/Veltis` writes the published build into `public/releases/.json`; the download page reads that first, falls back to the GitHub releases API, and never fabricates a build when neither is available. +- **PaperMC-Inspired Downloads:** Compact build browser with Minecraft version tabs, a pre-alpha/alpha/beta/release channel toggle, direct JAR downloads, and SHA-256 copy action. +- **Release Metadata Sync:** A `veltis-release` repository event from `VeltisMC/Veltis` validates the published build's metadata and stages it as a pull request into `public/releases/.json`; the download page reads that first, falls back to the GitHub releases API, and never fabricates a build when neither is available. - **Searchable Documentation:** Complete multi-section documentation covering Getting Started, Server Administration, System Architecture, and Migration. - **Global Command Palette (`Ctrl+K` / `⌘K`):** Instant client-side search across documentation articles, subheadings, and site pages. - **Developer Portal:** Proposed native capability-based plugin API specification, multithreading guidelines, and Gradle/Maven setup examples. -- **Architecture & Shulker Deep-Dive:** Interactive SVG system topology map and comprehensive guide to the Shulker patch engine. -- **Roadmap & Community Hub:** Filterable milestone board, contribution instructions, changelog timeline, and GPL-3.0 license statement. +- **Community Hub:** Contribution instructions, changelog timeline, and GPL-3.0 license statement. - **Vercel-Ready Deployment:** Client-side SPA routing (`vercel.json`), semantic markup, Open Graph tags, `robots.txt`, and `sitemap.xml`. --- @@ -35,8 +34,11 @@ Official website and developer portal for **Veltis**, the optimization-focused M The server repository builds and publishes each release, then sends this site a `veltis-release` repository event. `.github/workflows/sync-release.yml` validates -the payload, writes `public/releases/.json` (latest build plus capped -history for that channel only), and commits. Vercel redeploys on the push. +the payload, stages `public/releases/.json` (latest build plus capped +history for that channel only) on a dedicated `release-metadata/` +branch, opens a pull request against `main`, and lets the protected-branch rules +decide when the metadata lands. Vercel deploys a preview for the PR and the +production site on merge. At read time the download page prefers those files, falls back to the GitHub releases API, and finally shows an honest "no published releases" state. It never @@ -44,9 +46,16 @@ invents a version, a filename, or a download URL. ### Required setup +- `main` must be a protected branch with a required status check named `CI` + (from `.github/workflows/website-ci.yml`) and, if desired, a required review. + The sync workflow never disables or bypasses those rules. +- The `release-metadata/*` branch prefix must be writable by the workflow's + `GITHUB_TOKEN` (it is not itself protected). - The server repository must be granted a token that can send `repository_dispatch` - to this repository (`contents: write`). This workflow writes with the - repository's own `GITHUB_TOKEN`; only the sender needs a secret. + to this repository. Only the sender needs a secret; this workflow runs entirely + on its own `GITHUB_TOKEN`, with no repository secret. +- The metadata commit is signed by GitHub through the GraphQL + `createCommitOnBranch` mutation, so it shows as *Verified* without GPG. - No repository files under `public/releases/` should be edited by hand. --- @@ -105,7 +114,7 @@ export const siteConfig = { github: 'https://github.com/VeltisMC/Veltis', discord: 'https://discord.com/invite/239MkjVmZw', defaultMinecraftVersion: '26.3', - javaRequirement: 'Java 25+ (JDK 26)', + javaRequirement: 'Java 25+', ... } ``` diff --git a/public/sitemap.xml b/public/sitemap.xml index ca3d2a4..92f489f 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -65,21 +65,6 @@ weekly 0.8 - - https://www.veltismc.org/architecture - monthly - 0.8 - - - https://www.veltismc.org/shulker - monthly - 0.7 - - - https://www.veltismc.org/roadmap - weekly - 0.7 - https://www.veltismc.org/community monthly diff --git a/src/App.tsx b/src/App.tsx index 55efdfc..d93064a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,9 +11,6 @@ const CommunityPage = lazy(() => import('./pages/CommunityPage')) const ContributingPage = lazy(() => import('./pages/ContributingPage')) const ChangelogPage = lazy(() => import('./pages/ChangelogPage')) const LicensePage = lazy(() => import('./pages/LicensePage')) -const RoadmapPage = lazy(() => import('./pages/RoadmapPage')) -const ShulkerPage = lazy(() => import('./pages/ShulkerPage')) -const ArchitecturePage = lazy(() => import('./pages/ArchitecturePage')) const NotFoundPage = lazy(() => import('./pages/NotFoundPage')) function RouteFallback() { @@ -44,9 +41,6 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> {/* 404 Catch-All */} } /> diff --git a/src/components/common/ArchitectureDiagram.tsx b/src/components/common/ArchitectureDiagram.tsx deleted file mode 100644 index 8540e66..0000000 --- a/src/components/common/ArchitectureDiagram.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useState } from 'react' -import { Link } from 'react-router-dom' -import { Cpu, GitBranch, Layers, Zap, Database, ArrowRight } from 'lucide-react' - -interface NodeDetail { - id: string - title: string - role: string - description: string - details: string[] - badge: string -} - -export default function ArchitectureDiagram() { - const [activeNode, setActiveNode] = useState('core') - - const nodes: Record = { - shulker: { - id: 'shulker', - title: 'Shulker Patch Pipeline', - role: 'Upstream Source Ingestion & Patch Engine', - description: - 'Takes the decompiled vanilla Minecraft server source, applies Veltis\u2019s Git patch set, and verifies the resulting workspace. Upstream files are never edited in place.', - details: [ - 'Git-based patch application to the decompiled server source', - 'Workspace verification after patching', - 'Upstream history is not modified in place', - ], - badge: 'Patch Toolchain', - }, - core: { - id: 'core', - title: 'Veltis Core Runtime', - role: 'Java 25 Runtime & World State', - description: - 'The server a player connects to. It runs the game loop on a single authoritative thread and keeps world state consistent through the region-based world engine.', - details: [ - 'Java 25 runtime baseline', - 'Single authoritative game thread for world state', - 'Region-based world engine', - ], - badge: 'Core Runtime', - }, - tick: { - id: 'tick', - title: 'Tick Engine', - role: 'Single-Threaded Game Loop', - description: - 'Entity ticks, block physics and redstone run in order on the main thread, as the game requires. Ancillary asynchronous tasks run on virtual threads.', - details: [ - 'Game loop runs on the main thread, in order', - 'Virtual threads for ancillary asynchronous tasks', - 'Partitioned parallel ticking is a roadmap item and is not shipped', - ], - badge: 'Game Loop', - }, - io: { - id: 'io', - title: 'Networking & I/O', - role: 'Netty & Optional Off-Thread Compression', - description: - 'Networking runs on Netty worker threads. Veltis can additionally move packet compression off-thread; that optimisation is opt-in and disabled by default.', - details: [ - 'Netty worker threads for packet I/O', - 'Optional off-thread packet compression (default off)', - 'No SIMD or vector acceleration is claimed', - ], - badge: 'I/O & Networking', - }, - api: { - id: 'api', - title: 'Extension API (Planned)', - role: 'Not Yet Available', - description: - 'The veltis-api module is currently a placeholder. No plugin API, lifecycle hooks or Bukkit compatibility layer exist in current builds; these are roadmap items.', - details: [ - 'veltis-api is an empty placeholder today', - 'No lifecycle hooks are implemented', - 'A Bukkit/Paper bridge is a roadmap item, not shipped', - ], - badge: 'Planned', - }, - } - - const current = nodes[activeNode] || nodes.core - - return ( -
-
- {/* Visual Graph Area */} -
-
- SYSTEM INTERCONNECTION MAP - Click a component to inspect -
- -
- {/* Shulker Node */} - - - {/* Core Runtime Node */} - - - {/* Tick Scheduler Node */} - - - {/* Non-blocking IO Node */} - - - {/* Native Plugin API Node */} - -
-
- - {/* Selected Component Inspection Panel */} -
-
-
- - {current.badge} - - ID: {current.id} -
-

{current.title}

-

{current.role}

-

{current.description}

- -
- - Key Invariants: - -
    - {current.details.map((detail, idx) => ( -
  • - • - {detail} -
  • - ))} -
-
-
- -
- - Read detailed architecture specification - - -
-
-
-
- ) -} diff --git a/src/components/common/Footer.tsx b/src/components/common/Footer.tsx index 2bf818b..a213b5c 100644 --- a/src/components/common/Footer.tsx +++ b/src/components/common/Footer.tsx @@ -91,9 +91,6 @@ export default function Footer() { Contributing - - Roadmap - diff --git a/src/components/downloads/DownloadHero.test.tsx b/src/components/downloads/DownloadHero.test.tsx index 121f2ad..eb12aa5 100644 --- a/src/components/downloads/DownloadHero.test.tsx +++ b/src/components/downloads/DownloadHero.test.tsx @@ -21,11 +21,18 @@ function build(partial: Partial & { id: string; channel: BuildInfo['c } const release = build({ id: 'r', channel: 'release' }) -const nightly = build({ - id: 'n', - channel: 'nightly', - downloadUrl: 'https://github.com/VeltisMC/Veltis/releases/download/tag/nightly.jar', - artifact: 'Veltis 26.3 nightly 3077e84.jar', +const beta = build({ + id: 'b', + channel: 'beta', + downloadUrl: 'https://github.com/VeltisMC/Veltis/releases/download/tag/beta.jar', + artifact: 'Veltis 26.3 beta 3077e84.jar', + isPrerelease: true, +}) +const preAlpha = build({ + id: 'p', + channel: 'pre-alpha', + artifact: 'Veltis 26.3 pre-alpha 3077e84.jar', + isPrerelease: true, }) function noop() {} @@ -53,8 +60,8 @@ describe('DownloadHero', () => { render( { onRetry={noop} />, ) - expect(screen.getAllByText('Nightly').length).toBeGreaterThan(0) + expect(screen.getAllByText('Pre-alpha').length).toBeGreaterThan(0) }) it('never offers a download when no build is eligible', () => { @@ -85,7 +92,7 @@ describe('DownloadHero', () => { it('explains that no official release exists instead of faking one', () => { render( { const onSelectBuild = vi.fn() render( { fireEvent.click(screen.getByRole('button', { name: /Choose a different build/i })) const options = screen.getAllByRole('menuitemradio') expect(options).toHaveLength(2) - fireEvent.click(screen.getByRole('menuitemradio', { name: /Nightly build/i })) - expect(onSelectBuild).toHaveBeenCalledWith(nightly.id) + fireEvent.click(screen.getByRole('menuitemradio', { name: /Beta build/i })) + expect(onSelectBuild).toHaveBeenCalledWith(beta.id) }) -}) +}) \ No newline at end of file diff --git a/src/config/channels.test.ts b/src/config/channels.test.ts index 7e9135a..f61e3ca 100644 --- a/src/config/channels.test.ts +++ b/src/config/channels.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect } from 'vitest' import { CHANNELS, CHANNEL_IDS, + RELEASE_CANONICAL, canonicalChannel, channelDefinition, + isCurrentChannel, isReleaseChannel, } from './channels' @@ -23,26 +25,49 @@ describe('channel registry', () => { } }) - it('accepts every published channel, including the legacy names', () => { - for (const id of ['pre-alpha', 'alpha', 'beta', 'release', 'nightly', 'stable']) { + it('accepts every current channel plus the legacy stable alias, and nothing else', () => { + for (const id of ['pre-alpha', 'alpha', 'beta', 'release', 'stable']) { expect(isReleaseChannel(id)).toBe(true) expect(CHANNEL_IDS).toContain(id) } + expect(isReleaseChannel('nightly')).toBe(false) expect(isReleaseChannel('canary')).toBe(false) expect(isReleaseChannel(undefined)).toBe(false) }) + it('removes the retired nightly channel from every list and lookup', () => { + expect(CHANNEL_IDS).not.toContain('nightly') + expect(CHANNELS.find((c) => c.id === ('nightly' as string))).toBeUndefined() + expect(channelDefinition('nightly')).toBeUndefined() + expect(canonicalChannel('nightly')).toBeUndefined() + expect(CHANNELS.some((c) => c.label === 'Nightly')).toBe(false) + }) + + it('identifies exactly the four current channels as producible', () => { + for (const id of ['pre-alpha', 'alpha', 'beta', 'release']) { + expect(isCurrentChannel(id)).toBe(true) + } + // `stable` is a read-only legacy alias, never produced by a new build. + expect(isCurrentChannel('stable')).toBe(false) + expect(isCurrentChannel('nightly')).toBe(false) + }) + it('groups the legacy stable alias under release without renaming it', () => { expect(canonicalChannel('stable')).toBe('release') expect(canonicalChannel('release')).toBe('release') - expect(canonicalChannel('nightly')).toBe('nightly') + expect(canonicalChannel('pre-alpha')).toBe('pre-alpha') expect(canonicalChannel('unknown')).toBeUndefined() }) it('marks only the legacy identifiers as legacy', () => { - expect(channelDefinition('nightly')?.legacy).toBe(true) expect(channelDefinition('stable')?.legacy).toBe(true) expect(channelDefinition('pre-alpha')?.legacy).toBe(false) + expect(channelDefinition('beta')?.legacy).toBe(false) expect(channelDefinition('release')?.legacy).toBe(false) }) -}) + + it('names release as the canonical official channel', () => { + expect(RELEASE_CANONICAL).toBe('release') + expect(channelDefinition(RELEASE_CANONICAL)?.prerelease).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/config/channels.ts b/src/config/channels.ts index 05c3b82..8a38b99 100644 --- a/src/config/channels.ts +++ b/src/config/channels.ts @@ -9,17 +9,18 @@ * class for. * *

The taxonomy the organisation publishes under is `pre-alpha`, `alpha`, - * `beta` and `release`. Two older identifiers are kept alongside them because - * the build pipeline already published under them and their history must stay - * readable: + * `beta` and `release`. One older identifier is kept alongside them because the + * build pipeline already published under it and its history must stay readable: * - * * `nightly` is a channel of its own. It is NOT renamed to `alpha`, and a - * nightly build is never presented as stable. Its existing amber identity is - * preserved. * * `stable` is the earlier name for an official release. `stable` and * `release` are aliases of one concept, so `stable` maps to the canonical * `release` for grouping and display while the raw stored value is never * rewritten. + * + *

`nightly` is deliberately absent. It was never a supported current channel + * and is not renamed into one: a build that used it stays reachable through the + * original GitHub Releases, but it is not a channel the site offers, filters on + * or presents as a downloadable build. */ /** Every channel identifier the site will accept from stored metadata. */ @@ -28,8 +29,7 @@ export type ReleaseChannel = | 'alpha' | 'beta' | 'release' - // Legacy identifiers, retained for backwards compatibility. - | 'nightly' + // Legacy identifier, retained for backwards compatibility only. | 'stable' export interface ChannelDefinition { @@ -61,9 +61,9 @@ export interface ChannelDefinition { /** * The registry, in display order (most experimental first). The requested * colours are used exactly: pre-alpha `#8B0000`, alpha `#EF4444`, beta - * `#FACC15`, release `#A855F7`. The two legacy identifiers keep the identities - * the site already used so existing history is not recoloured into a different - * meaning. + * `#FACC15`, release `#A855F7`. The legacy `stable` identifier keeps the + * identity the site already used so existing history is not recoloured into a + * different meaning. */ export const CHANNELS: readonly ChannelDefinition[] = [ { @@ -90,18 +90,6 @@ export const CHANNELS: readonly ChannelDefinition[] = [ legacy: false, order: 1, }, - { - id: 'nightly', - label: 'Nightly', - shortLabel: 'Nightly', - description: 'Legacy nightly channel. Bleeding-edge builds, published automatically.', - color: '#F59E0B', - textColor: '#1C1917', - prerelease: true, - canonical: 'nightly', - legacy: true, - order: 2, - }, { id: 'beta', label: 'Beta', @@ -112,7 +100,7 @@ export const CHANNELS: readonly ChannelDefinition[] = [ prerelease: true, canonical: 'beta', legacy: false, - order: 3, + order: 2, }, { id: 'release', @@ -124,7 +112,7 @@ export const CHANNELS: readonly ChannelDefinition[] = [ prerelease: false, canonical: 'release', legacy: false, - order: 4, + order: 3, }, { id: 'stable', @@ -136,7 +124,7 @@ export const CHANNELS: readonly ChannelDefinition[] = [ prerelease: false, canonical: 'release', legacy: true, - order: 5, + order: 4, }, ] @@ -149,7 +137,8 @@ export const CHANNEL_IDS: readonly ReleaseChannel[] = CHANNELS.map((c) => c.id) /** * Identifiers the download page reads per-channel metadata files for. Ordered - * so the most relevant channel file is fetched first. + * so the most relevant channel file is fetched first. `stable` is included so a + * legacy file that was already published still reads. */ export const RELEASE_CHANNEL_IDS: readonly ReleaseChannel[] = CHANNELS.map((c) => c.id) @@ -158,6 +147,18 @@ export const PRIMARY_CHANNELS: readonly ChannelDefinition[] = CHANNELS.filter( (c) => !c.legacy, ) +/** + * True for the identifiers a new build may be published under — the four + * current channels. `stable` is excluded: it is read, never produced. + */ +export function isCurrentChannel(value: unknown): value is ReleaseChannel { + return ( + typeof value === 'string' && + value in BY_ID && + !BY_ID[value].legacy + ) +} + export function isReleaseChannel(value: unknown): value is ReleaseChannel { return typeof value === 'string' && value in BY_ID } diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 3849f1a..d26e2d0 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -24,7 +24,6 @@ export const footerSections: FooterSection[] = [ items: [ { href: '/downloads', label: 'Downloads' }, { href: '/changelog', label: 'Release Notes' }, - { href: '/roadmap', label: 'Project Roadmap' }, { href: '/developers', label: 'Developer Portal' }, ], }, @@ -41,8 +40,6 @@ export const footerSections: FooterSection[] = [ { title: 'Developers', items: [ - { href: '/architecture', label: 'System Architecture' }, - { href: '/shulker', label: 'Shulker Patch Engine' }, { href: '/developers', label: 'API Development (WIP)' }, { href: '/contributing', label: 'Contributing to Veltis' }, ], diff --git a/src/config/roadmap.ts b/src/config/roadmap.ts deleted file mode 100644 index f4737e8..0000000 --- a/src/config/roadmap.ts +++ /dev/null @@ -1,127 +0,0 @@ -export type RoadmapStatus = 'completed' | 'in-progress' | 'planned' | 'investigating' - -export interface RoadmapItem { - id: string - title: string - description: string - category: 'Runtime' | 'Patches' | 'API' | 'Tooling' | 'Networking' | 'Docs' - status: RoadmapStatus - /** - * A planning milestone, not a released version. Veltis has no `v0.x` release - * history; releases are channel + Minecraft version + commit (for example - * `Veltis 26.3 nightly 3077e84.jar`), so the roadmap deliberately does not - * name version numbers that do not exist. - */ - targetVersion?: string - githubIssue?: string - isDraftSample?: boolean -} - -export const roadmapStatusMeta: Record< - RoadmapStatus, - { label: string; badgeClass: string; description: string } -> = { - completed: { - label: 'Completed', - badgeClass: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30', - description: 'Verified features, tools, and infrastructure delivered in current builds.', - }, - 'in-progress': { - label: 'In Progress', - badgeClass: 'bg-violet-500/10 text-violet-400 border-violet-500/30', - description: 'Active design, patch development, and benchmarking underway.', - }, - planned: { - label: 'Planned', - badgeClass: 'bg-sky-500/10 text-sky-400 border-sky-500/30', - description: 'Scheduled implementation on the development roadmap for upcoming minor releases.', - }, - investigating: { - label: 'Under Investigation', - badgeClass: 'bg-amber-500/10 text-amber-400 border-amber-500/30', - description: 'Research spikes, architectural feasibility studies, and experimental prototypes.', - }, -} - -export const roadmapItems: RoadmapItem[] = [ - { - id: 'shulker-patch-engine', - title: 'Shulker Patch Engine Bootstrap', - description: 'Git-based patch application and generation tooling for the decompiled vanilla server source.', - category: 'Patches', - status: 'completed', - targetVersion: 'Foundation', - }, - { - id: 'modern-runtime-foundation', - title: 'Modern Runtime Foundation (Java 25+)', - description: 'Server runtime on a Java 25 baseline, using virtual threads for tick and task scheduling.', - category: 'Runtime', - status: 'completed', - targetVersion: 'Foundation', - }, - { - id: 'build-toolchain', - title: 'Automated CI/CD Build Toolchain', - description: 'Gradle build pipeline and GitHub Actions release workflow that builds, verifies and publishes one distribution.', - category: 'Tooling', - status: 'completed', - targetVersion: 'Foundation', - }, - { - id: 'parallel-tick-scheduler', - title: 'Partitioned Parallel Tick Scheduler', - description: 'Exploring partitioned entity and world tick scheduling while preserving game-state consistency.', - category: 'Runtime', - status: 'in-progress', - targetVersion: 'Runtime Preview', - }, - { - id: 'native-api-contract', - title: 'Veltis Extension API', - description: 'Designing a capability-driven extension API. The `veltis-api` module is currently a placeholder with no published classes.', - category: 'API', - status: 'planned', - targetVersion: 'Runtime Preview', - }, - { - id: 'memory-allocation-tuning', - title: 'Network Buffer / Allocation Reduction', - description: 'Investigating network buffer recycling and Netty pipeline tuning to reduce allocation pressure under load.', - category: 'Networking', - status: 'investigating', - targetVersion: 'Runtime Preview', - }, - { - id: 'bukkit-bridge-layer', - title: 'Bukkit / Paper Compatibility Bridge (Optional Subsystem)', - description: 'Optional compatibility layer that could allow existing Spigot and Paper plugins to run on top of the Veltis runtime. Not implemented.', - category: 'API', - status: 'planned', - targetVersion: 'Compatibility Layer', - }, - { - id: 'chunk-generation-concurrency', - title: 'Concurrent World Generation & Noise Cache Acceleration', - description: 'Off-thread terrain generation enhancements and native noise computation acceleration.', - category: 'Runtime', - status: 'planned', - targetVersion: 'Compatibility Layer', - }, - { - id: 'interactive-flamegraph-profiler', - title: 'Integrated Real-Time Tick Flamegraph Diagnostics', - description: 'Low-overhead continuous profiling interface exposed through an authenticated administrative endpoint.', - category: 'Tooling', - status: 'investigating', - targetVersion: 'Research', - }, - { - id: 'simd-chunk-compression', - title: 'SIMD-Accelerated Chunk Packet & Region Compression', - description: 'Investigating vector-accelerated compression for network traffic and region storage writing.', - category: 'Networking', - status: 'investigating', - targetVersion: 'Research', - }, -] diff --git a/src/pages/ArchitecturePage.tsx b/src/pages/ArchitecturePage.tsx deleted file mode 100644 index e83f6c6..0000000 --- a/src/pages/ArchitecturePage.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { Cpu } from 'lucide-react' -import ArchitectureDiagram from '../components/common/ArchitectureDiagram' -import Badge from '../components/common/Badge' - -export default function ArchitecturePage() { - return ( -

- {/* 1. Header Banner */} -
-
- ARCHITECTURE OVERVIEW - DESIGN INTENT -
-

- System Architecture -

-

- How Veltis is put together: how it applies its patches to the decompiled vanilla server, - which parts of the execution loop it changes today, and which changes are still design - goals. Anything not yet shipped is labelled as such — nothing here is presented as - delivered performance. -

-
- - {/* 2. System Topology Graph */} -
-
- - TOPOLOGY & SCHEMATICS - -

- End-to-End Runtime Pipeline -

-
- - -
- - {/* 3. Server Initialization Pipeline */} -
-
- - BOOTSTRAP SEQUENCE - -

- Server Initialization & Bootstrap -

-

- What happens between launching the JVM and the server accepting connections. This is the - bootstrap that exists today. -

-
- -
-
- 01 / JVM BOOT -

Runtime Sanity Check

-

- Confirms the Java 25+ runtime and assembles the module classpath before the server starts. -

-
- -
- 02 / PATCH MATRIX -

Shulker Workspace

-

- Applies the Git patch set to the decompiled vanilla server source and verifies the resulting workspace. -

-
- -
- 03 / TASK ENGINE -

Tick & Task Scheduling

-

- Starts the game loop and task scheduler. Ancillary tasks run on virtual threads. -

-
- -
- 04 / WORLD LOAD -

World Engine

-

- Loads dimensions through the region-based world engine and begins serving the network socket. -

-
-
-
- - {/* 4. Concurrency Model */} -
-
- - THREADING INVARIANTS - -

- Tick Execution and Threading -

-

- Veltis keeps a single authoritative game thread for world state. Entity ticking, block - physics and redstone run in that thread, in order, as the game requires. Networking stays - on Netty's own threads, and ancillary asynchronous tasks run on virtual threads - instead of holding a platform thread each. -

-
- -
-

- - Where work runs today -

- -
-
- Inbound packet ingestion & deserialization - Netty worker threads -
-
- Entity ticks, block physics, redstone (in order) - Main game thread -
-
- Ancillary asynchronous tasks - Virtual threads -
-
- Outbound packet dispatch - Netty worker threads -
-
- -

- Partitioning entity and pathfinding execution across worker pools is a roadmap item. It is - not implemented in current builds and is not described here as if it were. -

-
-
- - {/* 5. Network Pipeline */} -
-
- - NETWORK PIPELINE - -

- Optional Off-Thread Compression -

-
- -

- Packet compression is CPU work that otherwise runs while holding up packet delivery. Veltis - can hand that work to separate threads. The optimisation is opt-in and off by default; when - it is off, compression behaves exactly as it does upstream. -

- -
-

Status

-

- Enabled through configuration, disabled by default. No comparative performance figures are - published, because none have been benchmarked and reproduced. -

-
-
-
- ) -} diff --git a/src/pages/ChangelogPage.tsx b/src/pages/ChangelogPage.tsx index 13cf228..04f466b 100644 --- a/src/pages/ChangelogPage.tsx +++ b/src/pages/ChangelogPage.tsx @@ -4,6 +4,7 @@ import { siteConfig } from '../config/site' import { fetchReleases } from '../services/github' import type { BuildInfo } from '../types/release' import Badge from '../components/common/Badge' +import ChannelBadge from '../components/common/ChannelBadge' export default function ChangelogPage() { const [builds, setBuilds] = useState([]) @@ -71,17 +72,7 @@ export default function ChangelogPage() {

Veltis {build.minecraftVersion} {build.commitHash}

- - {build.channel.toUpperCase()} - + MC {build.minecraftVersion} diff --git a/src/pages/DownloadsPage.test.tsx b/src/pages/DownloadsPage.test.tsx index fe8bbe8..b8a8fe2 100644 --- a/src/pages/DownloadsPage.test.tsx +++ b/src/pages/DownloadsPage.test.tsx @@ -28,12 +28,12 @@ function build(partial: Partial & { id: string; channel: BuildInfo['c } const release = build({ id: 'r', channel: 'release' }) -const nightly = build({ - id: 'n', - channel: 'nightly', +const beta = build({ + id: 'b', + channel: 'beta', publishedAt: '2026-10-11T12:00:00Z', - downloadUrl: 'https://github.com/VeltisMC/Veltis/releases/download/tag/nightly.jar', - artifact: 'Veltis 26.3 nightly 3077e84.jar', + downloadUrl: 'https://github.com/VeltisMC/Veltis/releases/download/tag/beta.jar', + artifact: 'Veltis 26.3 beta 3077e84.jar', isPrerelease: true, }) @@ -50,29 +50,29 @@ describe('DownloadsPage', () => { fetchReleasesMock.mockReset() }) - it('downloads the latest official release when one exists, even if a newer nightly exists', async () => { - fetchReleasesMock.mockResolvedValue(result([nightly, release])) + it('downloads the latest official release when one exists, even if a newer beta exists', async () => { + fetchReleasesMock.mockResolvedValue(result([beta, release])) render() const link = await screen.findByRole('link', { name: `Download ${release.artifact}` }) expect(link).toHaveAttribute('href', release.downloadUrl) - // The nightlies are hidden until experimental builds are shown. - expect(screen.queryByText(nightly.artifact)).toBeNull() + // The experimental builds are hidden until the toggle is used. + expect(screen.queryByText(beta.artifact)).toBeNull() }) it('reveals experimental builds only when the toggle is used', async () => { - fetchReleasesMock.mockResolvedValue(result([nightly, release])) + fetchReleasesMock.mockResolvedValue(result([beta, release])) render() await screen.findByRole('link', { name: `Download ${release.artifact}` }) - expect(screen.queryByText(nightly.artifact)).toBeNull() + expect(screen.queryByText(beta.artifact)).toBeNull() fireEvent.click(screen.getByRole('button', { name: /Show experimental builds/i })) - expect(await screen.findByText(nightly.artifact)).toBeInTheDocument() + expect(await screen.findByText(beta.artifact)).toBeInTheDocument() }) it('shows an honest empty state and no download when only experimental builds exist', async () => { - fetchReleasesMock.mockResolvedValue(result([nightly])) + fetchReleasesMock.mockResolvedValue(result([beta])) render() await waitFor(() => expect(fetchReleasesMock).toHaveBeenCalled()) @@ -90,4 +90,4 @@ describe('DownloadsPage', () => { await screen.findByText(/No published builds were found for this version/i), ).toBeInTheDocument() }) -}) +}) \ No newline at end of file diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 12fb1b7..bfd591b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -23,8 +23,8 @@ export default function HomePage() { useEffect(() => { fetchReleases().then((res) => { const verObj = res.versions[siteConfig.defaultMinecraftVersion] - if (verObj && verObj.latestStable) { - setLatestBuild(verObj.latestStable) + if (verObj && verObj.latestRelease) { + setLatestBuild(verObj.latestRelease) } else if (verObj && verObj.builds.length > 0) { setLatestBuild(verObj.builds[0]) } diff --git a/src/pages/RoadmapPage.tsx b/src/pages/RoadmapPage.tsx deleted file mode 100644 index 2508ce2..0000000 --- a/src/pages/RoadmapPage.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useState } from 'react' -import { Clock, ExternalLink } from 'lucide-react' -import { roadmapItems, roadmapStatusMeta, type RoadmapStatus } from '../config/roadmap' -import Badge from '../components/common/Badge' - -export default function RoadmapPage() { - const [selectedStatus, setSelectedStatus] = useState('all') - const [selectedCategory, setSelectedCategory] = useState('all') - - const categories = ['all', 'Runtime', 'Patches', 'API', 'Tooling', 'Networking', 'Docs'] - const statuses: (RoadmapStatus | 'all')[] = ['all', 'completed', 'in-progress', 'planned', 'investigating'] - - const filteredItems = roadmapItems.filter((item) => { - const statusMatch = selectedStatus === 'all' || item.status === selectedStatus - const categoryMatch = selectedCategory === 'all' || item.category === selectedCategory - return statusMatch && categoryMatch - }) - - return ( -
- {/* 1. Header Banner */} -
-
- DEVELOPMENT TRACKER - PUBLIC MILESTONES -
-

- Project Roadmap -

-

- Transparent development priorities for the Veltis server software. Milestones are categorized into - completed features, active implementations, planned deliverables, and architectural research spikes. -

-
- - {/* 2. Filters */} -
- {/* Status Filter */} -
- Status: - {statuses.map((st) => ( - - ))} -
- - {/* Category Filter */} -
- Category: - -
-
- - {/* 3. Status Column Previews / Grid */} -
- {(['completed', 'in-progress', 'planned', 'investigating'] as RoadmapStatus[]).map((st) => { - const meta = roadmapStatusMeta[st] - const count = roadmapItems.filter((i) => i.status === st).length - return ( -
setSelectedStatus(st === selectedStatus ? 'all' : st)} - className={`p-4 rounded-xl border transition-all cursor-pointer ${ - selectedStatus === st - ? 'border-violet-500 bg-violet-950/20' - : 'border-brand-border bg-brand-surface/60 hover:border-slate-700' - }`} - > -
- - {meta.label} - - - {count} - -
-

{meta.description}

-
- ) - })} -
- - {/* 4. Roadmap Items List */} -
-
- Showing {filteredItems.length} milestone items -
- -
- {filteredItems.map((item) => { - const statusInfo = roadmapStatusMeta[item.status] - return ( -
-
-
- - {statusInfo.label} - - - {item.category} - -
- -

{item.title}

-

{item.description}

-
- -
- {item.targetVersion ? ( -
- - Target: {item.targetVersion} -
- ) : ( - Target: TBD - )} - - - Discuss on GitHub - - -
-
- ) - })} -
-
-
- ) -} diff --git a/src/pages/ShulkerPage.tsx b/src/pages/ShulkerPage.tsx deleted file mode 100644 index 9a7f9f0..0000000 --- a/src/pages/ShulkerPage.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import CodeBlock from '../components/common/CodeBlock' -import Badge from '../components/common/Badge' - -export default function ShulkerPage() { - return ( -
- {/* 1. Header Banner */} -
-
- PATCH MANAGEMENT ENGINE - DEV TOOLCHAIN -
-

- Shulker Patch Engine -

-

- The Git-based patching infrastructure that drives Veltis development. Shulker applies - reviewable modifications to the decompiled vanilla Minecraft server, so Veltis does not - have to maintain a large hand-edited fork. -

-
- - {/* 2. At a glance */} -
- - At a glance - -
-
-

Modules

-

- veltis-api · launcher · patch-engine · server -

-
-
-

Gradle tasks

-

- applyPatches · rebuildPatches · buildVeltisMC · benchmark -

-
-
-

- Patch files live in server/Shulker/code/. veltis-api is a - boundary that currently holds no classes; the server and runtime code live in{' '} - server and patch-engine. -

-
- - {/* 3. Why Veltis Uses Patches */} -
-
- - PHILOSOPHY & RATIONALE - -

- Why Use a Patch-Based Workflow? -

-
- -

- Maintaining a fork of a frequently updating upstream is difficult. Directly editing a - checked-in source tree leads to merge conflicts and messy rebases whenever the upstream - version changes. -

- -
-
-

Isolated Changes

-

- Each change exists as its own Git patch file. If one patch has an edge case, it can be - reverted without touching the others. -

-
- -
-

Re-base on New Versions

-

- When Mojang ships a new Minecraft version, the patch set is re-applied to the newly - decompiled sources. Conflicts surface as ordinary Git conflicts to resolve. -

-
- -
-

Readable Upstream Diff

-

- Every change Veltis makes to vanilla is visible as a patch against the decompiled - source. -

-
-
-
- - {/* 4. Directory Organization */} -
-
- - REPOSITORY STRUCTURE - -

- How Patches Are Organized -

-

- The patches applied to the decompiled server source, in application order: -

-
- -
-
server/
-
└── Shulker/
-
└── code/
-
├── 001-Improve-Command-Logging.patch
-
├── 002-Improve-DedicatedServer-Logging.patch
-
├── 003-Improve-PlayerList-Logging.patch
-
├── 004-Wire-VeltisBootstrap-Integration.patch
-
├── 005-Wire-VeltisBootstrap-Shutdown.patch
-
├── 006-Fix-Decompiled-PermissionLevel-Switch.patch
-
├── 007-Report-Total-Startup-Time.patch
-
├── 008-Fix-Decompiled-Hopper-GetEntities-Type-Witness.patch
-
├── 009-Hopper-Item-Lookup.patch
-
├── 010-Skip-Hopper-Search-When-Blocked.patch
-
├── 011-Fix-Decompiled-PathComputationType-Switch.patch
-
├── 012-Cache-Static-Collision-Shapes.patch
-
├── 013-Veltis-Built-in-Datapack.patch
-
├── 014-Sync-Veltis-Chunk-Activity.patch
-
└── 015-Off-Thread-Network-Compression.patch
-
-
- - {/* 5. Contributor Workflow */} -
-
- - DEVELOPMENT PROCESS - -

- Working with Shulker Patches -

-

- The pipeline applies patches to a working copy, you edit there, then export the result - back into patch files. -

-
- -
-

1. Apply the existing patch set

- - -

2. Edit in the applied working tree

-

- Make your change in the patched sources under minecraft/workspace/ and - commit it with Git. -

- -

3. Export commits back to patch files

- - -

4. Build the server

- -
-
- - {/* 6. Handling Conflicts */} -
-
- - MAINTENANCE - -

- Patch Conflict Resolution -

-

- When upstream changes conflict with an existing patch, application stops and leaves - standard Git conflict markers (<<<<<<<). Resolve the - conflict, stage the files, and continue the patch application. Running{' '} - ./gradlew rebuildPatches afterwards records the resolution back into the - patch files. -

-
-
-
- ) -} diff --git a/src/services/downloadSelection.test.ts b/src/services/downloadSelection.test.ts index 1b354d4..501196e 100644 --- a/src/services/downloadSelection.test.ts +++ b/src/services/downloadSelection.test.ts @@ -16,7 +16,7 @@ function build(partial: Partial & { id: string; channel: BuildInfo['c commitHash: '3077e84', commitMessage: 'build', downloadUrl: 'https://github.com/VeltisMC/Veltis/releases/download/tag/file.jar', - artifact: 'Veltis 26.3 nightly 3077e84.jar', + artifact: 'Veltis 26.3 pre-alpha 3077e84.jar', javaRequirement: 'Java 25+', changes: [], isPrerelease: partial.channel !== 'release' && partial.channel !== 'stable', @@ -26,38 +26,38 @@ function build(partial: Partial & { id: string; channel: BuildInfo['c const release = build({ id: 'r', channel: 'release', publishedAt: '2026-10-09T00:00:00Z' }) const stable = build({ id: 's', channel: 'stable', publishedAt: '2026-10-01T00:00:00Z' }) -const nightly = build({ id: 'n', channel: 'nightly', publishedAt: '2026-10-10T00:00:00Z' }) const preAlpha = build({ id: 'p', channel: 'pre-alpha', publishedAt: '2026-10-11T00:00:00Z' }) +const beta = build({ id: 'b', channel: 'beta', publishedAt: '2026-10-08T00:00:00Z' }) describe('download selection', () => { it('treats release and its legacy alias stable as the official line', () => { expect(isOfficialRelease(release)).toBe(true) expect(isOfficialRelease(stable)).toBe(true) - expect(isOfficialRelease(nightly)).toBe(false) expect(isOfficialRelease(preAlpha)).toBe(false) + expect(isOfficialRelease(beta)).toBe(false) }) it('hides experimental builds until they are requested', () => { - const all = [nightly, release, preAlpha] + const all = [preAlpha, release, beta] expect(eligibleBuilds(all, false).map((b) => b.id)).toEqual(['r']) - expect(eligibleBuilds(all, true).map((b) => b.id)).toEqual(['p', 'n', 'r']) + expect(eligibleBuilds(all, true).map((b) => b.id)).toEqual(['p', 'r', 'b']) }) it('selects the newest eligible build, newest first by publication time', () => { - expect(primaryBuild([release, stable, nightly], false)?.id).toBe('r') - expect(primaryBuild([release, stable, nightly], true)?.id).toBe('n') + expect(primaryBuild([release, stable, beta], false)?.id).toBe('r') + expect(primaryBuild([release, preAlpha], true)?.id).toBe('p') }) it('does not promote an experimental build while experimental builds are hidden', () => { // No release exists: with the toggle off there is nothing to offer. - expect(primaryBuild([nightly, preAlpha], false)).toBeUndefined() - // With the toggle on, the newest experimental build is offered (never labelled stable). - expect(primaryBuild([nightly, preAlpha], true)?.id).toBe('p') + expect(primaryBuild([preAlpha, beta], false)).toBeUndefined() + // With the toggle on, the newest experimental build is offered (never labelled release). + expect(primaryBuild([preAlpha, beta], true)?.id).toBe('p') }) it('reports whether any official release exists', () => { - expect(hasOfficialRelease([nightly])).toBe(false) - expect(hasOfficialRelease([nightly, release])).toBe(true) + expect(hasOfficialRelease([preAlpha])).toBe(false) + expect(hasOfficialRelease([preAlpha, release])).toBe(true) expect(hasOfficialRelease([stable])).toBe(true) }) -}) +}) \ No newline at end of file diff --git a/src/services/downloadSelection.ts b/src/services/downloadSelection.ts index 0664d19..2b3af6a 100644 --- a/src/services/downloadSelection.ts +++ b/src/services/downloadSelection.ts @@ -18,8 +18,8 @@ export function byNewest(builds: BuildInfo[]): BuildInfo[] { /** * The builds a visitor may currently pick from. When experimental builds are - * hidden, only the official release line remains — a nightly is never offered - * as if it were a release. + * hidden, only the official release line remains — an experimental build is + * never offered as if it were a release. */ export function eligibleBuilds(builds: BuildInfo[], includeExperimental: boolean): BuildInfo[] { const sorted = byNewest(builds) diff --git a/src/services/github.test.ts b/src/services/github.test.ts new file mode 100644 index 0000000..639e4f3 --- /dev/null +++ b/src/services/github.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchReleases } from './github' + +function okResponse(json: unknown): Response { + return { ok: true, status: 200, json: async () => json } as unknown as Response +} + +function notFound(): Response { + return { ok: false, status: 404 } as unknown as Response +} + +const stableFile = { + channel: 'stable', + latest: { + channel: 'stable', + version: '1.0.0-SNAPSHOT', + minecraftVersion: '26.3', + commit: '3077e845f1cc04d4d7c9466406b4d76f3df12810', + shortCommit: '3077e84', + artifact: 'Veltis 26.3 stable 3077e84.jar', + assetName: 'Veltis.26.3.stable.3077e84.jar', + distributionArtifact: 'veltis.jar', + downloadUrl: + 'https://github.com/VeltisMC/Veltis/releases/download/veltis-stable-26.3-3077e84/Veltis.26.3.stable.3077e84.jar', + releaseUrl: 'https://github.com/VeltisMC/Veltis/releases/tag/veltis-stable-26.3-3077e84', + publishedAt: '2026-10-10T12:00:00Z', + size: 100, + minimumJavaVersion: 25, + }, + history: [], +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('fetchReleases metadata path', () => { + it('displays the canonical spaced artifact name, never the dotted assetName', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + return url.endsWith('/stable.json') ? okResponse(stableFile) : notFound() + }), + ) + const result = await fetchReleases() + const builds = result.versions['26.3']?.builds ?? [] + expect(builds).toHaveLength(1) + expect(builds[0].artifact).toBe('Veltis 26.3 stable 3077e84.jar') + expect(builds[0].artifact).not.toContain('.stable.') + expect(builds[0].channel).toBe('stable') + expect(result.versions['26.3']?.latestRelease?.commitHash).toBe('3077e84') + }) + + it('rejects a metadata file whose channel is not supported', async () => { + const badNightly = { + ...stableFile, + channel: 'nightly', + latest: { ...stableFile.latest, channel: 'nightly' }, + } as unknown as typeof stableFile + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + return url.endsWith('/stable.json') ? okResponse(badNightly) : notFound() + }), + ) + const result = await fetchReleases() + const withBuilds = Object.values(result.versions).some((v) => v.builds.length > 0) + expect(withBuilds).toBe(false) + }) +}) + +describe('fetchReleases GitHub fallback', () => { + it('never infers a channel that a release does not encode', async () => { + const releases = [ + { + id: 1, + tag_name: 'veltis-something-26.3-aaaaaaa', + name: 'Veltis 26.3 something aaaaaaa', + body: '', + prerelease: true, + draft: false, + published_at: '2026-10-10T12:00:00Z', + html_url: 'https://github.com/VeltisMC/Veltis/releases/tag/x', + assets: [ + { + id: 1, + name: 'Veltis.26.3.something.aaaaaaa.jar', + browser_download_url: + 'https://github.com/VeltisMC/Veltis/releases/download/x/Veltis.26.3.something.aaaaaaa.jar', + size: 1, + created_at: '2026-10-10T12:00:00Z', + }, + ], + }, + { + id: 2, + tag_name: 'veltis-nightly-26.3-bbbbbbb', + name: 'Veltis 26.3 nightly bbbbbbb', + body: '', + prerelease: true, + draft: false, + published_at: '2026-10-10T13:00:00Z', + html_url: 'https://github.com/VeltisMC/Veltis/releases/tag/n', + assets: [ + { + id: 2, + name: 'Veltis.26.3.nightly.bbbbbbb.jar', + browser_download_url: + 'https://github.com/VeltisMC/Veltis/releases/download/n/Veltis.26.3.nightly.bbbbbbb.jar', + size: 1, + created_at: '2026-10-10T13:00:00Z', + }, + ], + }, + { + id: 3, + tag_name: 'veltis-pre-alpha-26.3-ccccccc', + name: 'Veltis 26.3 pre-alpha ccccccc', + body: '', + prerelease: true, + draft: false, + published_at: '2026-10-10T14:00:00Z', + html_url: 'https://github.com/VeltisMC/Veltis/releases/tag/p', + assets: [ + { + id: 3, + name: 'Veltis.26.3.pre-alpha.ccccccc.jar', + browser_download_url: + 'https://github.com/VeltisMC/Veltis/releases/download/p/Veltis.26.3.pre-alpha.ccccccc.jar', + size: 1, + created_at: '2026-10-10T14:00:00Z', + }, + ], + }, + ] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.startsWith('https://api.github.com/')) return okResponse(releases) + return notFound() + }), + ) + const result = await fetchReleases() + const builds = result.versions['26.3']?.builds ?? [] + // Neither the unknown "something" build (prerelease, but no supported + // channel in the name) nor the retired nightly build is offered; only the + // explicitly-encoded pre-alpha release survives. + expect(builds).toHaveLength(1) + expect(builds[0].channel).toBe('pre-alpha') + }) +}) \ No newline at end of file diff --git a/src/services/github.ts b/src/services/github.ts index 1b54ae1..10fb92c 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -7,7 +7,11 @@ import type { VersionReleases, } from '../types/release' import { siteConfig } from '../config/site' -import { RELEASE_CHANNEL_IDS, isReleaseChannel } from '../config/channels' +import { + RELEASE_CHANNEL_IDS, + canonicalChannel, + isReleaseChannel, +} from '../config/channels' import { githubAssetName } from './releaseValidation' const CHANNELS: readonly ReleaseChannel[] = RELEASE_CHANNEL_IDS @@ -15,15 +19,17 @@ const CHANNELS: readonly ReleaseChannel[] = RELEASE_CHANNEL_IDS /** * The published filename, parsed so a GitHub-release-only fallback can recover * the same facts the metadata file carries. Two spellings exist for one name: - * the human `Veltis 26.3 nightly aaa8824.jar`, and the form GitHub stores, - * `Veltis.26.3.nightly.aaa8824.jar` — GitHub normalizes every release asset - * name, so the releases API only ever returns the dotted one. + * the human `Veltis 26.3 pre-alpha aaa8824.jar`, and the form GitHub stores, + * `Veltis.26.3.pre-alpha.aaa8824.jar` — GitHub normalizes every release asset + * name, so the releases API only ever returns the dotted one. `stable` is still + * accepted: it is the legacy name for `release` and older builds published under + * it must stay readable. `nightly` is not: it was never a supported channel. */ const ARTIFACT_PATTERN = - /^Veltis[ .](.+?)[ .](pre-alpha|alpha|beta|release|nightly|stable)[ .]([0-9a-f]{7})\.jar$/ + /^Veltis[ .](.+?)[ .](pre-alpha|alpha|beta|release|stable)[ .]([0-9a-f]{7})\.jar$/ /** The release tag CI writes: `veltis---`. */ -const TAG_PATTERN = /^veltis-(pre-alpha|alpha|beta|release|nightly|stable)-(.+)-([0-9a-f]{7})$/ +const TAG_PATTERN = /^veltis-(pre-alpha|alpha|beta|release|stable)-(.+)-([0-9a-f]{7})$/ export interface ReleaseFetchResult { versions: Record @@ -38,11 +44,12 @@ function commitUrl(commit: string): string { /** Turns one channel-metadata entry into the shape the download UI renders. */ function fromChannelRelease(release: ChannelRelease): BuildInfo { const short = release.shortCommit || release.commit.slice(0, 7) - // The release stores the asset under `assetName` (GitHub folds the spaces of - // `artifact` into dots); that is the file the download URL serves, so it is - // the filename the UI shows. Older payloads without it fall back to `artifact`. - const artifact = - release.assetName && release.assetName.length > 0 ? release.assetName : release.artifact + // The canonical human filename is `artifact` — the spaced + // `Veltis .jar` the release is titled with. That is + // what the button shows and what a person copies into a command line. + // `assetName` is the same file as GitHub stores it (spaces folded to dots) + // and is validated elsewhere, but it is never presented as the artifact name. + const artifact = release.artifact return { id: release.commit || release.artifact, version: release.version, @@ -62,16 +69,10 @@ function fromChannelRelease(release: ChannelRelease): BuildInfo { ? `Java ${release.minimumJavaVersion}+` : siteConfig.javaRequirement, changes: [], - isPrerelease: CHANNEL_PRERELEASE(release.channel), + isPrerelease: canonicalChannel(release.channel) !== 'release', } } -function CHANNEL_PRERELEASE(channel: ReleaseChannel): boolean { - // Kept local so the service does not depend on the presentation module at - // runtime; the canonical answer lives in config/channels. - return channel !== 'release' && channel !== 'stable' -} - /** * Validates one channel metadata file before any of it is trusted. The website * workflow validates the payload on arrival; this validates it again at read @@ -154,7 +155,10 @@ function parseGitHubRelease(release: GitHubRelease): BuildInfo | null { const fromAsset = jar ? ARTIFACT_PATTERN.exec(jar.name) : null const fromTag = TAG_PATTERN.exec(tag) - const channel = fromAsset?.[2] ?? fromTag?.[1] ?? (release.prerelease ? 'nightly' : 'stable') + const channel = fromAsset?.[2] ?? fromTag?.[1] + // A GitHub Release carries no channel of its own, and no inference from + // `prerelease` or the tag is ever made: when the asset or tag does not name a + // supported channel, the release is not offered rather than being guessed. if (!isReleaseChannel(channel)) return null const minecraftVersion = fromAsset?.[1] ?? fromTag?.[2] ?? siteConfig.defaultMinecraftVersion @@ -271,9 +275,10 @@ function groupBuildsByVersion(builds: BuildInfo[]): Record b.publishedAt.localeCompare(a.publishedAt)) - entry.latestStable = entry.builds.find((b) => b.channel === 'release' || b.channel === 'stable') - entry.latestBeta = entry.builds.find((b) => b.channel === 'beta') - entry.latestNightly = entry.builds.find((b) => b.channel === 'nightly') + entry.latestRelease = entry.builds.find( + (b) => canonicalChannel(b.channel) === 'release', + ) + entry.latestBeta = entry.builds.find((b) => canonicalChannel(b.channel) === 'beta') } return versions diff --git a/src/services/releaseValidation.test.ts b/src/services/releaseValidation.test.ts index 143fe74..f126594 100644 --- a/src/services/releaseValidation.test.ts +++ b/src/services/releaseValidation.test.ts @@ -59,99 +59,106 @@ function expectAccepted(result: ReturnType): NormalizedRelease describe('canonical filename rules', () => { it('folds the human title into the name GitHub stores', () => { - expect(githubAssetName('Veltis 26.3 nightly 3077e84.jar')).toBe( - 'Veltis.26.3.nightly.3077e84.jar', + expect(githubAssetName('Veltis 26.3 pre-alpha 3077e84.jar')).toBe( + 'Veltis.26.3.pre-alpha.3077e84.jar', ) }) it('collapses already-dotted input idempotently', () => { - expect(githubAssetName('Veltis.26.3.nightly.3077e84.jar')).toBe( - 'Veltis.26.3.nightly.3077e84.jar', + expect(githubAssetName('Veltis.26.3.pre-alpha.3077e84.jar')).toBe( + 'Veltis.26.3.pre-alpha.3077e84.jar', + ) + }) + + it('keeps hyphens inside a channel name intact when folding spaces', () => { + expect(githubAssetName('Veltis 26.3 release 3077e84.jar')).toBe( + 'Veltis.26.3.release.3077e84.jar', ) }) }) describe('validateReleasePayload', () => { - it('accepts a valid nightly payload (the one that was failing)', () => { - const value = expectAccepted(validate(payloadFor('nightly'))) - expect(value.channel).toBe('nightly') - expect(value.assetName).toBe('Veltis.26.3.nightly.3077e84.jar') + it('accepts a valid pre-alpha payload', () => { + const value = expectAccepted(validate(payloadFor('pre-alpha'))) + expect(value.channel).toBe('pre-alpha') + expect(value.assetName).toBe('Veltis.26.3.pre-alpha.3077e84.jar') expect(value.minimumJavaVersion).toBe(25) }) - it('accepts valid pre-alpha, alpha, beta, release and legacy stable payloads', () => { - for (const channel of ['pre-alpha', 'alpha', 'beta', 'release', 'stable']) { + it('accepts valid alpha, beta, release and legacy stable payloads', () => { + for (const channel of ['alpha', 'beta', 'release', 'stable']) { const value = expectAccepted(validate(payloadFor(channel))) expect(value.channel).toBe(channel) expect(value.artifact).toBe(`Veltis 26.3 ${channel} ${SHORT}.jar`) } }) - it('rejects an unsupported channel', () => { + it('rejects an unsupported channel, including the retired nightly', () => { expectRejected(validate(payloadFor('stable', { metadata: { channel: 'canary' } })), 'metadata.channel') + expectRejected(validate(payloadFor('stable', { metadata: { channel: 'nightly' } })), 'metadata.channel') }) it('rejects the dotted-versus-spaced mismatch (assetName not GitHub form of artifact)', () => { - const broken = payloadFor('nightly') - broken.metadata!.assetName = 'Veltis 26.3 nightly 3077e84.jar' + const broken = payloadFor('pre-alpha') + broken.metadata!.assetName = 'Veltis 26.3 pre-alpha 3077e84.jar' expectRejected(validate(broken), 'metadata.assetName') }) it('rejects artifact and assetName that disagree', () => { - const broken = payloadFor('nightly') - broken.metadata!.assetName = 'Veltis.26.3.nightly.deadbee.jar' + const broken = payloadFor('pre-alpha') + broken.metadata!.assetName = 'Veltis.26.3.pre-alpha.deadbee.jar' expectRejected(validate(broken), 'metadata.assetName') }) it('rejects a malformed download URL', () => { - const broken = payloadFor('nightly', { release: { downloadUrl: 'ftp://example.com/x.jar' } }) + const broken = payloadFor('pre-alpha', { release: { downloadUrl: 'ftp://example.com/x.jar' } }) expectRejected(validate(broken), 'release.downloadUrl') }) it('rejects a URL for the wrong release tag', () => { - const broken = payloadFor('nightly', { - release: { downloadUrl: `https://github.com/${REPO}/releases/download/veltis-nightly-26.3-deadbee/Veltis.26.3.nightly.3077e84.jar` }, + const broken = payloadFor('pre-alpha', { + release: { downloadUrl: `https://github.com/${REPO}/releases/download/veltis-pre-alpha-26.3-deadbee/Veltis.26.3.pre-alpha.3077e84.jar` }, }) expectRejected(validate(broken), 'release.downloadUrl') }) it('rejects a URL for the wrong repository', () => { - const broken = payloadFor('nightly', { - release: { downloadUrl: `https://github.com/SomeoneElse/Veltis/releases/download/${releaseTag('nightly', '26.3', SHORT)}/Veltis.26.3.nightly.3077e84.jar` }, + const broken = payloadFor('pre-alpha', { + release: { downloadUrl: `https://github.com/SomeoneElse/Veltis/releases/download/${releaseTag('pre-alpha', '26.3', SHORT)}/Veltis.26.3.pre-alpha.3077e84.jar` }, }) expectRejected(validate(broken), 'release.downloadUrl') }) it('rejects a download URL whose asset does not match assetName', () => { - const broken = payloadFor('nightly', { - release: { downloadUrl: `https://github.com/${REPO}/releases/download/${releaseTag('nightly', '26.3', SHORT)}/other.jar` }, + const broken = payloadFor('pre-alpha', { + release: { downloadUrl: `https://github.com/${REPO}/releases/download/${releaseTag('pre-alpha', '26.3', SHORT)}/other.jar` }, }) expectRejected(validate(broken), 'release.downloadUrl') }) it('rejects a releaseUrl pointing at a different tag', () => { - const broken = payloadFor('nightly', { - release: { releaseUrl: `https://github.com/${REPO}/releases/tag/veltis-nightly-26.3-deadbee` }, + const broken = payloadFor('pre-alpha', { + release: { releaseUrl: `https://github.com/${REPO}/releases/tag/veltis-pre-alpha-26.3-deadbee` }, }) expectRejected(validate(broken), 'release.releaseUrl') }) it('rejects a short commit that is not the prefix of the full commit', () => { - const broken = payloadFor('nightly', { metadata: { shortCommit: 'deadbee' } }) + const broken = payloadFor('pre-alpha', { metadata: { shortCommit: 'deadbee' } }) expectRejected(validate(broken), 'metadata.shortCommit') }) it('rejects a malformed full commit', () => { - expectRejected(validate(payloadFor('nightly', { metadata: { commit: 'not-a-sha' } })), 'metadata.commit') + expectRejected(validate(payloadFor('pre-alpha', { metadata: { commit: 'not-a-sha' } })), 'metadata.commit') }) it('rejects missing required metadata', () => { - expectRejected(validate(payloadFor('nightly', { metadata: { version: '' } })), 'metadata.version') - expectRejected(validate(payloadFor('nightly', { metadata: { minecraftVersion: '' } })), 'metadata.minecraftVersion') + expectRejected(validate(payloadFor('pre-alpha', { metadata: { version: '' } })), 'metadata.version') + expectRejected(validate(payloadFor('pre-alpha', { metadata: { minecraftVersion: '' } })), 'metadata.minecraftVersion') }) it('rejects an artifact that does not follow the canonical convention', () => { - const broken = payloadFor('nightly') + const broken = payloadFor('pre-alpha') broken.metadata!.artifact = 'veltis.jar' // assetName now disagrees as well, but the artifact rule must fire too. const result = validate(broken) @@ -160,34 +167,34 @@ describe('validateReleasePayload', () => { it('rejects a non-constant distribution artifact', () => { expectRejected( - validate(payloadFor('nightly', { metadata: { distributionArtifact: 'Veltis 26.3 nightly 3077e84.jar' } })), + validate(payloadFor('pre-alpha', { metadata: { distributionArtifact: 'Veltis 26.3 pre-alpha 3077e84.jar' } })), 'metadata.distributionArtifact', ) }) it('rejects an invalid Java requirement', () => { - expectRejected(validate(payloadFor('nightly', { metadata: { minimumJavaVersion: 0 } })), 'metadata.minimumJavaVersion') - expectRejected(validate(payloadFor('nightly', { metadata: { minimumJavaVersion: 3.5 } })), 'metadata.minimumJavaVersion') + expectRejected(validate(payloadFor('pre-alpha', { metadata: { minimumJavaVersion: 0 } })), 'metadata.minimumJavaVersion') + expectRejected(validate(payloadFor('pre-alpha', { metadata: { minimumJavaVersion: 3.5 } })), 'metadata.minimumJavaVersion') }) it('accepts a payload that omits an optional size and Java requirement', () => { const value = expectAccepted( - validate(payloadFor('nightly', { metadata: { minimumJavaVersion: null }, release: { size: null } })), + validate(payloadFor('pre-alpha', { metadata: { minimumJavaVersion: null }, release: { size: null } })), ) expect(value.minimumJavaVersion).toBeUndefined() expect(value.size).toBeUndefined() }) it('rejects a non-positive size', () => { - expectRejected(validate(payloadFor('nightly', { release: { size: 0 } })), 'release.size') + expectRejected(validate(payloadFor('pre-alpha', { release: { size: 0 } })), 'release.size') }) it('rejects an unparseable publication timestamp', () => { - expectRejected(validate(payloadFor('nightly', { release: { publishedAt: 'yesterday' } })), 'release.publishedAt') + expectRejected(validate(payloadFor('pre-alpha', { release: { publishedAt: 'yesterday' } })), 'release.publishedAt') }) it('rejects a payload missing the metadata or release half', () => { expectRejected(validate({ release: {} }), 'metadata') expectRejected(validate({ metadata: {} }), 'release') }) -}) +}) \ No newline at end of file diff --git a/src/services/releaseValidation.ts b/src/services/releaseValidation.ts index fedddfc..059b3c3 100644 --- a/src/services/releaseValidation.ts +++ b/src/services/releaseValidation.ts @@ -166,7 +166,7 @@ export function validateReleasePayload( // 2. Channel must be explicitly supported. const channelRaw = metadata.channel if (!accept(channelRaw)) { - fail('metadata.channel', 'is not a supported channel', 'pre-alpha|alpha|beta|release|nightly|stable', String(channelRaw)) + fail('metadata.channel', 'is not a supported channel', 'pre-alpha|alpha|beta|release|stable', String(channelRaw)) return { ok: false, issues } } const channel = channelRaw diff --git a/src/services/searchIndex.ts b/src/services/searchIndex.ts index fc753f9..5451509 100644 --- a/src/services/searchIndex.ts +++ b/src/services/searchIndex.ts @@ -17,10 +17,10 @@ export function getSearchIndex(): SearchResultItem[] { { id: 'page-downloads', title: 'Downloads & Releases', - description: 'Download the latest stable, beta, and nightly builds for Minecraft 26.3 and more.', + description: 'Download the latest pre-alpha, alpha, beta, or release builds for Minecraft 26.3 and more.', url: '/downloads', category: 'Pages', - keywords: ['download', 'jar', 'releases', 'builds', 'stable', 'beta', 'nightly', 'sha256'], + keywords: ['download', 'jar', 'releases', 'builds', 'pre-alpha', 'alpha', 'beta', 'release', 'sha256'], }, { id: 'page-developers', @@ -30,30 +30,6 @@ export function getSearchIndex(): SearchResultItem[] { category: 'API', keywords: ['developers', 'api', 'plugin', 'extension', 'gradle', 'maven'], }, - { - id: 'page-architecture', - title: 'System Architecture', - description: 'How Veltis is assembled from the decompiled vanilla server, the Shulker patch set, and the patched runtime.', - url: '/architecture', - category: 'Pages', - keywords: ['architecture', 'runtime', 'tick', 'netty', 'patches'], - }, - { - id: 'page-shulker', - title: 'Shulker Patch Engine', - description: 'The Git-based patch pipeline that applies reviewable changes to the decompiled vanilla server.', - url: '/shulker', - category: 'Pages', - keywords: ['shulker', 'patches', 'git', 'rebase', 'upstream', 'decompilation'], - }, - { - id: 'page-roadmap', - title: 'Project Roadmap', - description: 'Development milestones: completed, in-progress, planned, and research investigations.', - url: '/roadmap', - category: 'Pages', - keywords: ['roadmap', 'milestones', 'progress', 'future', 'issues'], - }, { id: 'page-community', title: 'Community & Support', diff --git a/src/services/workflowConsistency.test.ts b/src/services/workflowConsistency.test.ts index 9a4c588..1e5768c 100644 --- a/src/services/workflowConsistency.test.ts +++ b/src/services/workflowConsistency.test.ts @@ -7,7 +7,9 @@ import process from 'node:process' * The workflow and the TypeScript validator express the same rules in two * languages. This does not re-implement `jq`, but it does fail if the workflow * loses a supported channel or the canonical-name / URL checks, so the two - * cannot silently drift apart. + * cannot silently drift apart. It also locks down the protected-branch + * guarantees: no push to `main`, no force push, verified-signature commits, + * and a PR that runs the required checks. */ const workflow = readFileSync( resolve(process.cwd(), '.github', 'workflows', 'sync-release.yml'), @@ -15,12 +17,16 @@ const workflow = readFileSync( ) describe('sync-release workflow', () => { - it('accepts every published channel', () => { - for (const channel of ['pre-alpha', 'alpha', 'beta', 'release', 'nightly', 'stable']) { - expect(workflow).toContain(channel) + it('accepts every current channel and the legacy stable alias', () => { + for (const channel of ['pre-alpha', 'alpha', 'beta', 'release', 'stable']) { + expect(workflow).toContain(`"${channel}"`) } }) + it('never accepts or produces the retired nightly channel', () => { + expect(workflow.toLowerCase()).not.toContain('nightly') + }) + it('keeps the canonical filename and URL rules', () => { expect(workflow).toContain('githubAssetName') expect(workflow).toContain('releases/download') @@ -32,10 +38,42 @@ describe('sync-release workflow', () => { expect(workflow).toContain('RELEASE_REPOSITORY: VeltisMC/Veltis') }) - it('validates before it writes channel metadata', () => { + it('validates the payload before it stages channel metadata', () => { const validateIndex = workflow.indexOf('Validate the release payload') - const updateIndex = workflow.indexOf('Update the channel metadata') + const stageIndex = workflow.indexOf('Stage the channel metadata') expect(validateIndex).toBeGreaterThan(-1) - expect(updateIndex).toBeGreaterThan(validateIndex) + expect(stageIndex).toBeGreaterThan(validateIndex) + }) + + it('never pushes to main and never force-pushes', () => { + // The checkout target is main for reading only; every write goes to a + // dedicated release-metadata branch. + expect(workflow).toContain('ref: main') + expect(workflow).toMatch(/release-metadata\//) + expect(workflow).not.toMatch(/git push/) + expect(workflow).not.toMatch(/--force/) + }) + + it('writes metadata through a protected-branch-safe PR flow', () => { + // Verified commits come from the createCommitOnBranch GraphQL mutation; + // the PR is created against main and never auto-merged. + expect(workflow).toContain('createCommitOnBranch') + expect(workflow).toContain('gh pr create') + expect(workflow).toContain('--base main') + expect(workflow.toLowerCase()).toContain('auto-merg') + }) + + it('is idempotent across repeated dispatches', () => { + // Dedupe by commit and early-exit when the build is already recorded. + expect(workflow).toContain('unique_by(.commit)') + expect(workflow).toContain('already recorded on main') + expect(workflow).toContain('already carries this release') + expect(workflow).toContain('noop') + }) + + it('supplies the required checks on the metadata branch', () => { + // A PR opened with GITHUB_TOKEN does not fire pull_request workflows, so + // the workflow must dispatch the CI workflow explicitly on the branch. + expect(workflow).toContain('gh workflow run website-ci.yml') }) -}) +}) \ No newline at end of file diff --git a/src/types/release.ts b/src/types/release.ts index 8acb516..6f8940b 100644 --- a/src/types/release.ts +++ b/src/types/release.ts @@ -40,9 +40,8 @@ export interface BuildInfo { export interface VersionReleases { minecraftVersion: string - latestStable?: BuildInfo + latestRelease?: BuildInfo latestBeta?: BuildInfo - latestNightly?: BuildInfo builds: BuildInfo[] } @@ -70,7 +69,7 @@ export interface GitHubRelease { * One build's publication metadata, exactly as the Veltis repository's CI * dispatches it and as this site stores it at `/releases/.json`. * - * `artifact` is the human release title (`Veltis 26.3 nightly 3077e84.jar`); + * `artifact` is the human release title (`Veltis 26.3 pre-alpha 3077e84.jar`); * `distributionArtifact` is the constant local name (`veltis.jar`) the build * produced before CI renamed it; `assetName` is the name GitHub actually stores * the asset under. All three are different facts about the same build. diff --git a/src/website-cleanup.test.ts b/src/website-cleanup.test.ts new file mode 100644 index 0000000..c93447d --- /dev/null +++ b/src/website-cleanup.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import process from 'node:process' + +const src = (p: string) => resolve(process.cwd(), 'src', p) + +describe('removed website sections', () => { + const removedRoutes = ['/roadmap', '/shulker', '/architecture'] + + it('has no routes, nav entries, footer links or search results for the removed pages', () => { + const app = readFileSync(src('App.tsx'), 'utf8') + for (const route of removedRoutes) { + expect(app).not.toContain(`path="${route}"`) + } + + const nav = readFileSync(src('config/navigation.ts'), 'utf8') + for (const route of removedRoutes) { + expect(nav).not.toContain(route) + } + + const footer = readFileSync(src('components/common/Footer.tsx'), 'utf8') + expect(footer).not.toContain('/roadmap') + + const search = readFileSync(src('services/searchIndex.ts'), 'utf8') + for (const route of removedRoutes) { + expect(search).not.toContain(`url: '${route}'`) + } + }) + + it('removes the pages and their supporting modules from the tree', () => { + const gone = [ + 'pages/RoadmapPage.tsx', + 'pages/ShulkerPage.tsx', + 'pages/ArchitecturePage.tsx', + 'config/roadmap.ts', + 'components/common/ArchitectureDiagram.tsx', + ] + for (const file of gone) { + expect(existsSync(src(file)), `${file} should have been removed`).toBe(false) + } + }) + + it('drops the removed pages from the sitemap', () => { + const sitemap = readFileSync(resolve(process.cwd(), 'public', 'sitemap.xml'), 'utf8') + for (const route of ['/architecture', '/shulker', '/roadmap']) { + expect(sitemap).not.toContain(`veltismc.org${route}`) + } + }) +}) + +describe('nightly is fully retired on the website', () => { + // Comments may explain the retirement, so the check is against the functional + // forms only: quoted literals and identifiers that would make `nightly` a + // usable value, never against prose. + const functionalNightly = [/'nightly'/, /"nightly"/, /latestNightly/] + const files = [ + 'config/channels.ts', + 'types/release.ts', + 'services/github.ts', + 'services/releaseValidation.ts', + 'services/downloadSelection.ts', + 'services/searchIndex.ts', + 'components/common/ChannelBadge.tsx', + 'components/downloads/DownloadHero.tsx', + 'pages/ChangelogPage.tsx', + 'pages/DownloadsPage.tsx', + 'pages/HomePage.tsx', + ] + + it('never uses nightly as a value, key, selector or identifier', () => { + for (const file of files) { + const content = readFileSync(src(file), 'utf8') + for (const pattern of functionalNightly) { + expect(content, `${file} still uses nightly as a value`).not.toMatch(pattern) + } + } + }) +}) \ No newline at end of file