diff --git a/.github/workflows/link-checker-full.yaml b/.github/workflows/link-checker-full.yaml index e7ab021d..d33ffb95 100644 --- a/.github/workflows/link-checker-full.yaml +++ b/.github/workflows/link-checker-full.yaml @@ -12,7 +12,7 @@ jobs: runs-on: "ubuntu-latest" timeout-minutes: 15 steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6 - name: "Link Checker" uses: "filiph/linkcheck@3.0.0" with: diff --git a/.github/workflows/link-checker.yaml b/.github/workflows/link-checker.yaml index af27c842..0091d4a7 100644 --- a/.github/workflows/link-checker.yaml +++ b/.github/workflows/link-checker.yaml @@ -14,13 +14,67 @@ jobs: preview_url: "${{ steps.waitForVercelPreviewDeployment.outputs.url }}" steps: - name: "Wait for Vercel preview deployment to be ready" - uses: "patrickedqvist/wait-for-vercel-preview@v1.3.1" + uses: "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3" # v9 id: "waitForVercelPreviewDeployment" with: - token: "${{ secrets.GITHUB_TOKEN }}" - check_interval: 30 - max_timeout: 600 - allow_inactive: true + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const isPullRequest = Boolean(context.payload.pull_request); + + // The docs repo deploys via its own workflows (vercel-preview.yml, + // vercel-production.yml), which create the GitHub Deployment as + // github-actions[bot], not vercel[bot]. + const sha = isPullRequest + ? context.payload.pull_request.head.sha + : context.sha; + const environment = isPullRequest + ? "Preview (GitHub Actions)" + : "Production"; + + const checkIntervalSeconds = 30; + const maxTimeoutSeconds = 600; + const iterations = Math.floor(maxTimeoutSeconds / checkIntervalSeconds); + + core.info("Waiting 30 seconds for deployment workflow to start..."); + await new Promise((resolve) => setTimeout(resolve, 30000)); + + for (let i = 0; i < iterations; i++) { + const { data: deployments } = await github.rest.repos.listDeployments({ + owner, + repo, + sha, + environment, + }); + + const deployment = deployments.sort((a, b) => b.id - a.id)[0]; + + if (deployment) { + const { data: statuses } = await github.rest.repos.listDeploymentStatuses({ + owner, + repo, + deployment_id: deployment.id, + }); + + const status = statuses[0]; + + if (status && status.state === "success" && status.environment_url) { + core.info(`Deployment successful! URL: ${status.environment_url}`); + core.setOutput("url", status.environment_url); + return; + } + + if (status && ["failure", "error", "inactive", "cancelled"].includes(status.state)) { + core.setFailed(`Vercel deployment ${deployment.id} reported state "${status.state}"`); + return; + } + } + + core.info(`Waiting for Vercel deployment (attempt ${i + 1} / ${iterations})`); + await new Promise((resolve) => setTimeout(resolve, checkIntervalSeconds * 1000)); + } + + core.setFailed(`Timed out waiting for a successful Vercel deployment on ${sha}`); link_checker: name: "Link Checker" @@ -28,8 +82,13 @@ jobs: needs: "preview" timeout-minutes: 15 steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6 - name: "Link Checker" uses: "filiph/linkcheck@3.0.0" with: - arguments: "--skip-file /github/workspace/linkcheck-skip.txt ${{ needs.preview.outputs.preview_url }}" + # --no-check-anchors: Nextra doesn't render heading `id`s into the + # static HTML server-side (they're attached client-side after + # hydration), so the crawler flags every #fragment link as a + # missing anchor. link-checker-full.yaml already disables this for + # the same reason. + arguments: "--skip-file /github/workspace/linkcheck-skip.txt --no-check-anchors ${{ needs.preview.outputs.preview_url }}" diff --git a/CLAUDE.md b/CLAUDE.md index 6e68e755..e9ab5365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,12 @@ pnpm format # Format code pnpm lint:markdown # Lint markdown files ``` +## Known Quirks + +- **Nextra breadcrumb 404s on section index paths.** `nextra-theme-docs`'s breadcrumb component links a top-level section's crumb (e.g. "SpiceDB", from `app/_meta.ts`) to `item.children[0].route` when that section has no index page of its own — the bare path of its first child folder (per that folder's `_meta.ts` order), not a recursively-resolved real page. If that first child also has no index page (true for `spicedb/getting-started`, `authzed/guides`, `materialize/getting-started`), every page in the section renders a breadcrumb crumb pointing at a 404. No content ever links to these bare paths directly — only the breadcrumb does, dynamically, on every page under that section — so the fix is a permanent redirect in `next.config.mjs` from the bare path to the intended real page, not an index page or a content edit. Watch for this again if a new top-level section (or a new first-listed subsection) is added without its own index page. +- **Link checker needs `--no-check-anchors`.** Nextra doesn't render heading `id`s into server-rendered HTML — they're attached client-side after hydration — so a static-HTML crawler (`filiph/linkcheck`, used by `link-checker.yaml`/`link-checker-full.yaml`) flags every `#fragment` link as a false-positive missing anchor without this flag. Both link-checker workflows pass it. +- **The Vercel preview-deploy wait step doesn't use `patrickedqvist/wait-for-vercel-preview`.** That action hardcodes `actorName: 'vercel[bot]'`, but this repo's own `vercel-preview.yml`/`vercel-production.yml` create GitHub Deployments as `github-actions[bot]` (no native Vercel GitHub App integration), so the action always timed out. `link-checker.yaml`'s `preview` job instead queries the Deployments API directly by commit sha + environment (`"Preview (GitHub Actions)"` for PRs, `"Production"` for pushes to main) via an inline `actions/github-script` step, mirroring the equivalent step in `authzed/web`'s `test.yml`. + ## Notes - Markdown linting is lenient (most rules disabled, custom sentence-per-line rule) diff --git a/next.config.mjs b/next.config.mjs index 102ee3aa..76933028 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -77,6 +77,26 @@ export default withNextra({ destination: "/spicedb/integrations/langchain-spicedb", permanent: true, }, + // /spicedb/getting-started, /authzed/guides, and /materialize/getting-started + // are section folders with no index page of their own. Nextra's breadcrumb + // component links a section's top-level crumb (e.g. "SpiceDB") to its first + // child folder's bare route rather than resolving to a real leaf page, so + // without these redirects those crumbs 404. + { + source: "/spicedb/getting-started", + destination: "/spicedb/getting-started/discovering-spicedb", + permanent: true, + }, + { + source: "/authzed/guides", + destination: "/authzed/guides/cloud", + permanent: true, + }, + { + source: "/materialize/getting-started", + destination: "/materialize/getting-started/overview", + permanent: true, + }, ]; }, // This is necessary because we're using CDN domains.