diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 6cce4096..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Build - -on: - pull_request: - branches: - - main - - "release/*" - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: "22" - - - name: Install dependencies - run: yarn install - - - name: Build Docusaurus site - run: yarn build diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..56f787ca --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,93 @@ +name: deploy-prod + +# Builds the docs on every push to the default branch and publishes them to the +# ROOT of the gh-pages branch — the production site — while preserving any +# pr-preview/ directories so PR previews are never clobbered. +# +# Runs in the trusted base-repo context (push to the default branch), so it has +# write access and needs no fork-safety split. +# +# Environment is driven by repo Actions variables, so the SAME workflow serves the +# github.io sub-path, a staging custom domain, and production with no code edits: +# vars.DOCS_URL default: https://.github.io +# vars.DOCS_BASE_URL default: // (set to "/" for a root custom domain) +# vars.PAGES_CNAME optional: when set, written as the gh-pages CNAME each deploy + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +# Shared with preview-deploy so prod and preview writes to gh-pages never race. +concurrency: + group: gh-pages-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: yarn + + - run: yarn install + + - name: Build production site + env: + DOCS_URL: ${{ vars.DOCS_URL || format('https://{0}.github.io', github.repository_owner) }} + DOCS_BASE_URL: ${{ vars.DOCS_BASE_URL || format('/{0}/', github.event.repository.name) }} + run: yarn build + + - name: Publish to gh-pages root (preserve pr-preview/) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PAGES_CNAME: ${{ vars.PAGES_CNAME }} + run: | + set -euo pipefail + REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + if git clone --quiet --depth 1 --branch gh-pages --single-branch "$REPO_URL" gh-pages 2>/dev/null; then + cd gh-pages + else + mkdir gh-pages && cd gh-pages + git init --quiet + git remote add origin "$REPO_URL" + git checkout --quiet --orphan gh-pages + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Replace the production site at the root while keeping pr-preview/ (and .git). + # Defined as a function so it can be replayed verbatim on a push retry. + publish() { + find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'pr-preview' -exec rm -rf {} + + cp -R "${GITHUB_WORKSPACE}/build/." . + touch .nojekyll # serve Docusaurus output verbatim (skip GitHub Pages' Jekyll) + if [ -n "${PAGES_CNAME:-}" ]; then echo "$PAGES_CNAME" > CNAME; fi + git add -A + } + + publish + if git diff --quiet --cached; then + echo "No changes to publish." + exit 0 + fi + git commit --quiet -m "Deploy production ($GITHUB_SHA)" + + # Serialized by the concurrency group; if the branch moved anyway, adopt the + # latest tree and replay (never touching pr-preview/) before retrying. + if ! git push --quiet origin gh-pages; then + echo "Push rejected; re-syncing gh-pages and retrying." + git fetch --quiet --depth 1 origin gh-pages + git reset --hard origin/gh-pages + publish + git diff --quiet --cached || git commit --quiet -m "Deploy production ($GITHUB_SHA)" + git push --quiet origin gh-pages + fi diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml new file mode 100644 index 00000000..4ecadb69 --- /dev/null +++ b/.github/workflows/preview-build.yml @@ -0,0 +1,85 @@ +name: preview-build + +# Builds the docs for every PR and uploads the result as an artifact. +# +# SECURITY: this is triggered by `pull_request` (NOT `pull_request_target`), so code +# from forked PRs runs with a read-only token and NO access to repository secrets. This +# job only ever produces a build artifact. All privileged work (publishing to GitHub +# Pages, commenting on the PR) happens in the separate `preview-deploy` workflow, which +# runs in the trusted base-repo context and never executes PR code. + +on: + pull_request: + branches: [main, "release/*"] + types: [opened, synchronize, reopened, closed] + +permissions: + contents: read + +# One build per PR; newer pushes cancel in-flight builds for the same PR. +concurrency: + group: preview-build-${{ github.event.number }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + # Untrusted PR code runs in this job; don't leave the auth token behind in + # .git/config where a build script could read it. + persist-credentials: false + + # Carry the PR number + event action across to the trusted deploy workflow. + # The `workflow_run` event does not expose the PR number for forked PRs, so we + # pass it explicitly via this artifact. + - name: Save PR metadata + run: | + mkdir -p pr-meta + echo "${{ github.event.number }}" > pr-meta/number + echo "${{ github.event.action }}" > pr-meta/action + - name: Upload PR metadata + uses: actions/upload-artifact@v4 + with: + name: pr-meta + path: pr-meta/ + retention-days: 1 + if-no-files-found: error + + # On `closed` the deploy workflow only needs the metadata above to tear the + # preview down, so skip the (slow) build entirely. + - name: Set up Node.js + if: github.event.action != 'closed' + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: yarn + + # Plain `yarn install` to mirror the upstream build (build.yml / amplify.yml). + - name: Install dependencies + if: github.event.action != 'closed' + run: yarn install + + - name: Build Docusaurus site + if: github.event.action != 'closed' + env: + # Point this build at the path the PR will be published to. When a root custom + # domain is configured (the PAGES_CNAME repo variable), previews live at + # /pr-preview/pr-/; otherwise at the github.io project sub-path + # .github.io//pr-preview/pr-/. Defaults in docusaurus.config.js + # keep production builds unchanged. + DOCS_URL: ${{ vars.PAGES_CNAME && format('https://{0}', vars.PAGES_CNAME) || format('https://{0}.github.io', github.repository_owner) }} + DOCS_BASE_URL: ${{ vars.PAGES_CNAME && format('/pr-preview/pr-{0}/', github.event.number) || format('/{0}/pr-preview/pr-{1}/', github.event.repository.name, github.event.number) }} + run: yarn build + + - name: Upload site artifact + if: github.event.action != 'closed' + uses: actions/upload-artifact@v4 + with: + name: pr-preview-site + path: build/ + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 00000000..4ed6f1b1 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,186 @@ +name: preview-deploy + +# Publishes (and tears down) the per-PR preview produced by `preview-build`. +# +# This workflow runs from the DEFAULT branch in the trusted base-repo context, so it has +# write access to the repository. It NEVER executes PR code: it only downloads the +# already-built static artifact and publishes it to the `gh-pages` branch under +# pr-preview/pr-/. This is the GitHub-recommended pattern for safely handling +# artifacts that were built from forked pull requests. + +on: + workflow_run: + workflows: ["preview-build"] + types: [completed] + +permissions: + contents: write # push to gh-pages + pull-requests: write # post/update the preview comment + issues: write # the PR comment is posted via the issues endpoint + actions: read # download artifacts from the triggering run + +# Serialize all writes to the shared gh-pages branch across every PR. +concurrency: + group: gh-pages-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 10 + # Only act on successful builds that originated from a pull_request. + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + steps: + - name: Download PR metadata + uses: actions/download-artifact@v4 + with: + name: pr-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: pr-meta + + - name: Read and validate PR metadata + id: meta + run: | + set -euo pipefail + number="$(cat pr-meta/number)" + action="$(cat pr-meta/action)" + # pr-meta is produced in the untrusted build context, so validate before + # using it in filesystem paths / API calls (a forked PR could tamper with it). + if ! [[ "$number" =~ ^[0-9]+$ ]]; then + echo "::error::Refusing to deploy: PR number '$number' is not a positive integer." + exit 1 + fi + echo "number=$number" >> "$GITHUB_OUTPUT" + echo "action=$action" >> "$GITHUB_OUTPUT" + + - name: Download site artifact + if: steps.meta.outputs.action != 'closed' + uses: actions/download-artifact@v4 + with: + name: pr-preview-site + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: site + + - name: Publish or remove preview on gh-pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.meta.outputs.number }} + PR_ACTION: ${{ steps.meta.outputs.action }} + SRC_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + TARGET="pr-preview/pr-${PR_NUMBER}" + + # A successful (non-close) build must contain a homepage; bail rather than + # publish a broken/empty preview. + if [ "$PR_ACTION" != "closed" ] && [ ! -f "${GITHUB_WORKSPACE}/site/index.html" ]; then + echo "::error::Built site artifact is missing index.html; aborting." + exit 1 + fi + + # Shallow clone of just gh-pages (fast even as its history grows), or start an + # orphan branch on the very first deploy. + if git clone --quiet --depth 1 --branch gh-pages --single-branch "$REPO_URL" gh-pages 2>/dev/null; then + cd gh-pages + else + mkdir gh-pages && cd gh-pages + git init --quiet + git remote add origin "$REPO_URL" + git checkout --quiet --orphan gh-pages + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Re-apply only this PR's directory onto the current tree, leaving every other + # PR's preview intact. Idempotent, so it is safe to replay on a push retry. + apply_change() { + rm -rf "$TARGET" + if [ "$PR_ACTION" != "closed" ]; then + mkdir -p "$TARGET" + cp -R "${GITHUB_WORKSPACE}/site/." "$TARGET/" + fi + touch .nojekyll # serve Docusaurus output verbatim (skip GitHub Pages' Jekyll) + git add -A + } + + if [ "$PR_ACTION" != "closed" ]; then + MSG="Deploy preview for PR #${PR_NUMBER} (${SRC_SHA})" + else + MSG="Remove preview for PR #${PR_NUMBER}" + fi + + apply_change + if git diff --quiet --cached; then + echo "Nothing to publish." + exit 0 + fi + git commit --quiet -m "$MSG" + + # Deploys are serialized by the workflow concurrency group, so a plain push + # should succeed. If the branch moved anyway, adopt the latest tree and + # re-apply our isolated change (never clobbering other PRs) before retrying. + if ! git push --quiet origin gh-pages; then + echo "Push rejected; re-syncing gh-pages and retrying." + git fetch --quiet --depth 1 origin gh-pages + git reset --hard origin/gh-pages + apply_change + git diff --quiet --cached || git commit --quiet -m "$MSG" + git push --quiet origin gh-pages + fi + + - name: Comment the preview URL on the PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.meta.outputs.number }} + PR_ACTION: ${{ steps.meta.outputs.action }} + SRC_SHA: ${{ github.event.workflow_run.head_sha }} + PAGES_CNAME: ${{ vars.PAGES_CNAME }} + run: | + set -euo pipefail + OWNER="${GITHUB_REPOSITORY%%/*}" + REPO="${GITHUB_REPOSITORY##*/}" + # When a root custom domain is configured, previews are served at + # /pr-preview/pr-/; otherwise at the github.io project sub-path. + if [ -n "${PAGES_CNAME:-}" ]; then + URL="https://${PAGES_CNAME}/pr-preview/pr-${PR_NUMBER}/" + else + OWNER_LC="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')" + URL="https://${OWNER_LC}.github.io/${REPO}/pr-preview/pr-${PR_NUMBER}/" + fi + SHORT_SHA="${SRC_SHA:0:7}" + MARKER="" + + if [ "$PR_ACTION" != "closed" ]; then + { + echo "$MARKER" + echo "### 📖 Docs preview is ready" + echo "" + echo "**Preview:** $URL" + echo "" + echo "Built from commit \`$SHORT_SHA\` and published to GitHub Pages. Updates on every push; removed automatically when this PR is closed." + } > body.md + else + { + echo "$MARKER" + echo "### 📖 Docs preview removed" + echo "" + echo "The preview for this PR was taken down because the PR is now closed." + } > body.md + fi + + # Sticky behaviour: update our existing marked comment if present, else create it. + existing="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq "[.[] | select(.body | contains(\"${MARKER}\"))][0].id // empty")" + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -f body="$(cat body.md)" >/dev/null + echo "Updated comment $existing" + else + gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$(cat body.md)" >/dev/null + echo "Created preview comment" + fi + echo "Preview URL: $URL" diff --git a/docusaurus.config.js b/docusaurus.config.js index 0ffcc7af..b3edf0a8 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -56,8 +56,12 @@ const openApiDocsConfig = versions.reduce((config, version) => { const config = { title: "Cartesi Documentation", tagline: "Application-specific rollups with a Linux runtime.", - url: "https://docs.cartesi.io", - baseUrl: "/", + // url/baseUrl default to production (AWS Amplify @ docs.cartesi.io). PR-preview + // builds override them via env vars to publish under a GitHub Pages sub-path + // (https://.github.io//pr-preview/pr-/). The vars are unset for + // local builds and on Amplify, so production behavior is unchanged. + url: process.env.DOCS_URL || "https://docs.cartesi.io", + baseUrl: process.env.DOCS_BASE_URL || "/", trailingSlash: true, onBrokenLinks: "ignore", onBrokenMarkdownLinks: "throw", diff --git a/plugins/serve-markdown.js b/plugins/serve-markdown.js index 309b48f2..a2439f19 100644 --- a/plugins/serve-markdown.js +++ b/plugins/serve-markdown.js @@ -46,6 +46,26 @@ function resolveSource(siteDir, source) { return source; } +/** + * Strip the site baseUrl from a permalink so paths are baseUrl-relative. + * + * Docusaurus permalinks include the configured baseUrl, but the build output + * (and the URLs people request) are relative to it. Under a sub-path baseUrl — + * e.g. GitHub Pages PR previews at //pr-preview/pr-/ — using the raw + * permalink writes .md files to a baseUrl-nested dead path and breaks the + * llms.txt index/alias matching. At baseUrl "/" this is a no-op. + * + * @param {string} permalink + * @param {string} baseUrl e.g. "/" or "/repo/pr-preview/pr-12/" + * @returns {string} + */ +function stripBaseUrl(permalink, baseUrl) { + if (baseUrl && baseUrl !== '/' && permalink.startsWith(baseUrl)) { + return '/' + permalink.slice(baseUrl.length); + } + return permalink; +} + /** * Walk the allContent object and collect every { permalink → absFilePath } * pair from all @docusaurus/plugin-content-docs instances. @@ -58,9 +78,10 @@ function resolveSource(siteDir, source) { * * @param {Record>} allContent * @param {string} siteDir + * @param {string} baseUrl site baseUrl; keys are stored baseUrl-relative * @returns {Map} permalink → absolute source path */ -function buildUrlMap(allContent, siteDir) { +function buildUrlMap(allContent, siteDir, baseUrl) { const map = new Map(); for (const instancesById of Object.values(allContent)) { @@ -75,9 +96,13 @@ function buildUrlMap(allContent, siteDir) { for (const doc of docs) { const { permalink, source } = doc; if (!permalink || !source) continue; + // Store keys baseUrl-relative so the .md output paths and the llms + // index/alias routes line up under any baseUrl (incl. sub-path + // previews). No-op when baseUrl is "/". + const sitePermalink = stripBaseUrl(permalink, baseUrl); // Normalize: always store with trailing slash so toPermalink lookups // are consistent regardless of how Docusaurus emits the permalink. - const normalised = permalink.endsWith('/') ? permalink : permalink + '/'; + const normalised = sitePermalink.endsWith('/') ? sitePermalink : sitePermalink + '/'; map.set(normalised, resolveSource(siteDir, source)); } } @@ -386,7 +411,7 @@ module.exports = function serveMarkdownPlugin(context) { // contentLoaded, and before configureWebpack / postBuild. // async allContentLoaded({ allContent }) { - const discovered = buildUrlMap(allContent, siteDir); + const discovered = buildUrlMap(allContent, siteDir, siteConfig.baseUrl); for (const [permalink, absPath] of discovered) { urlMap.set(permalink, absPath); } diff --git a/src/components/SelectVersion/DropdownVersion.js b/src/components/SelectVersion/DropdownVersion.js index df00bf79..1004e1a9 100644 --- a/src/components/SelectVersion/DropdownVersion.js +++ b/src/components/SelectVersion/DropdownVersion.js @@ -5,6 +5,7 @@ import { } from "@docusaurus/plugin-content-docs/client"; import { useLocation } from "@docusaurus/router"; import useGlobalData from "@docusaurus/useGlobalData"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import clsx from "clsx"; @@ -14,11 +15,21 @@ import NavbarItem from "@theme/NavbarItem"; const useVersionsForDropdown = () => { const global = useGlobalData(); const location = useLocation(); + const { + siteConfig: { baseUrl }, + } = useDocusaurusContext(); const sidebars = Object.keys(global["docusaurus-plugin-content-docs"]); + // Detect the active docs plugin from the first path segment *after* the site + // baseUrl. Using the raw pathname breaks when the site is served from a + // sub-path (e.g. GitHub Pages PR previews at //pr-preview/pr-/), + // where the first segment is the baseUrl prefix instead of the docs route. const docsPluginId = sidebars.find((sidebar) => { - const path = location.pathname.split("/")[1]; - return sidebar.includes(path); + const relativePath = location.pathname.startsWith(baseUrl) + ? location.pathname.slice(baseUrl.length) + : location.pathname.replace(/^\/+/, ""); + const path = relativePath.split("/")[0]; + return path && sidebar.includes(path); }); const versions = useVersions(docsPluginId); diff --git a/src/pages/index.js b/src/pages/index.js index 8f58658e..4cab78f0 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -1,10 +1,14 @@ import React from "react"; import { Redirect } from "@docusaurus/router"; +import useBaseUrl from "@docusaurus/useBaseUrl"; function Home() { // return ; // return ; - return ; + // useBaseUrl keeps the redirect correct under any baseUrl. Production uses "/" + // (target unchanged), but PR previews are served from a sub-path where a bare + // "/get-started" would resolve outside the preview and 404. + return ; } export default Home;