diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 82d8cb4..3684572 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -10,6 +10,9 @@ permissions: jobs: build: runs-on: ubuntu-latest + env: + DOCS_SITE_URL: ${{ vars.DOCS_SITE_URL }} + DOCS_BASE_URL: ${{ vars.DOCS_BASE_URL }} defaults: run: working-directory: website diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 3480c6a..cc44329 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -7,17 +7,19 @@ on: workflow_dispatch: permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: - group: pages + group: docs-static-branch cancel-in-progress: true jobs: - build: + build-and-publish: runs-on: ubuntu-latest + env: + DOCS_SITE_URL: ${{ vars.DOCS_SITE_URL }} + DOCS_BASE_URL: ${{ vars.DOCS_BASE_URL }} + DOCS_DEPLOY_BRANCH: ${{ vars.DOCS_DEPLOY_BRANCH }} defaults: run: working-directory: website @@ -25,9 +27,6 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Setup Node uses: actions/setup-node@v4 with: @@ -43,18 +42,31 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} run: npm run build - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: website/build + - name: Publish built static files to deploy branch + working-directory: . + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + BRANCH="${DOCS_DEPLOY_BRANCH:-gh-pages}" + BUILD_DIR="${GITHUB_WORKSPACE}/website/build" + PUBLISH_DIR="$(mktemp -d)" + + if [ ! -f "${BUILD_DIR}/index.html" ]; then + echo "Expected built docs at ${BUILD_DIR}/index.html" >&2 + exit 1 + fi + + cp -a "${BUILD_DIR}/." "${PUBLISH_DIR}/" + touch "${PUBLISH_DIR}/.nojekyll" + + cd "${PUBLISH_DIR}" + git init + git checkout -b "${BRANCH}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add --all + git commit -m "Publish docs for ${GITHUB_SHA}" + git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push --force origin "HEAD:${BRANCH}" diff --git a/scripts/self-hosted-docs/README.md b/scripts/self-hosted-docs/README.md new file mode 100644 index 0000000..13bb278 --- /dev/null +++ b/scripts/self-hosted-docs/README.md @@ -0,0 +1,39 @@ +# Self-Hosted Docs Scripts + +These scripts are for a pull-based static docs deploy. + +The idea is: + +1. GitHub Actions builds the Docusaurus site. +2. GitHub Actions publishes only the built static files to a deploy branch. +3. The server pulls that deploy branch. +4. The server copies the static files into a timestamped release directory. +5. The web server serves the `current` symlink target. + +These scripts are meant to run on the server as a dedicated non-root docs user. + +## Default Assumptions + +- repo URL: `https://github.com/morganross/apicostx-docs.git` +- deploy branch: `gh-pages` +- docs root: `/srv/apicostx-docs` + +## Scripts + +- `clone-or-update-docs-static.sh` + - clones or updates the deploy branch checkout +- `publish-docs-static.sh` + - copies the checkout into a new release directory and atomically switches `current` +- `verify-docs-static.sh` + - checks that the release looks like a safe Docusaurus static site +- `rollback-docs-static.sh` + - points `current` back to an older release +- `deploy-docs-static.sh` + - convenience wrapper: update repo, publish release, verify result + +## Important Security Notes + +- Do not run these scripts as `root`. +- Do not point the web server directly at the Git checkout. +- If the deploy branch is private, use a read-only deploy key on the server. +- Only the built static branch should be cloned onto the server. diff --git a/scripts/self-hosted-docs/clone-or-update-docs-static.sh b/scripts/self-hosted-docs/clone-or-update-docs-static.sh new file mode 100755 index 0000000..4979245 --- /dev/null +++ b/scripts/self-hosted-docs/clone-or-update-docs-static.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -eq 0 ]; then + echo "Do not run this script as root. Use a dedicated docs user." >&2 + exit 1 +fi + +DOCS_REPO_URL="${DOCS_REPO_URL:-https://github.com/morganross/apicostx-docs.git}" +DOCS_BRANCH="${DOCS_BRANCH:-gh-pages}" +DOCS_ROOT="${DOCS_ROOT:-/srv/apicostx-docs}" +DOCS_REPO_DIR="${DOCS_REPO_DIR:-${DOCS_ROOT}/repo}" + +mkdir -p "${DOCS_ROOT}" + +if [ ! -d "${DOCS_REPO_DIR}/.git" ]; then + git clone --depth 1 --branch "${DOCS_BRANCH}" "${DOCS_REPO_URL}" "${DOCS_REPO_DIR}" +else + git -C "${DOCS_REPO_DIR}" remote set-url origin "${DOCS_REPO_URL}" + git -C "${DOCS_REPO_DIR}" fetch --depth 1 origin "${DOCS_BRANCH}" + git -C "${DOCS_REPO_DIR}" checkout -B "${DOCS_BRANCH}" "origin/${DOCS_BRANCH}" + git -C "${DOCS_REPO_DIR}" reset --hard "origin/${DOCS_BRANCH}" + git -C "${DOCS_REPO_DIR}" clean -fdx +fi + +git -C "${DOCS_REPO_DIR}" rev-parse HEAD diff --git a/scripts/self-hosted-docs/deploy-docs-static.sh b/scripts/self-hosted-docs/deploy-docs-static.sh new file mode 100755 index 0000000..283d13f --- /dev/null +++ b/scripts/self-hosted-docs/deploy-docs-static.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +"${SCRIPT_DIR}/clone-or-update-docs-static.sh" +"${SCRIPT_DIR}/publish-docs-static.sh" diff --git a/scripts/self-hosted-docs/publish-docs-static.sh b/scripts/self-hosted-docs/publish-docs-static.sh new file mode 100755 index 0000000..8ac9445 --- /dev/null +++ b/scripts/self-hosted-docs/publish-docs-static.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -eq 0 ]; then + echo "Do not run this script as root. Use a dedicated docs user." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +DOCS_ROOT="${DOCS_ROOT:-/srv/apicostx-docs}" +DOCS_REPO_DIR="${DOCS_REPO_DIR:-${DOCS_ROOT}/repo}" +DOCS_RELEASES_DIR="${DOCS_RELEASES_DIR:-${DOCS_ROOT}/releases}" +DOCS_CURRENT_LINK="${DOCS_CURRENT_LINK:-${DOCS_ROOT}/current}" + +if ! command -v rsync >/dev/null 2>&1; then + echo "rsync is required for publish-docs-static.sh" >&2 + exit 1 +fi + +if [ ! -d "${DOCS_REPO_DIR}" ]; then + echo "Docs repo checkout not found: ${DOCS_REPO_DIR}" >&2 + exit 1 +fi + +mkdir -p "${DOCS_RELEASES_DIR}" + +TIMESTAMP="$(date -u +%Y-%m-%dT%H%M%SZ)" +RELEASE_DIR="${DOCS_RELEASES_DIR}/${TIMESTAMP}" +TEMP_LINK="${DOCS_CURRENT_LINK}.tmp" + +mkdir -p "${RELEASE_DIR}" + +rsync -a --delete \ + --exclude '.git' \ + --exclude '.github' \ + "${DOCS_REPO_DIR}/" "${RELEASE_DIR}/" + +"${SCRIPT_DIR}/verify-docs-static.sh" "${RELEASE_DIR}" + +ln -sfn "${RELEASE_DIR}" "${TEMP_LINK}" +mv -Tf "${TEMP_LINK}" "${DOCS_CURRENT_LINK}" + +echo "Published static docs release: ${RELEASE_DIR}" +echo "Current symlink now points to: ${DOCS_CURRENT_LINK}" diff --git a/scripts/self-hosted-docs/rollback-docs-static.sh b/scripts/self-hosted-docs/rollback-docs-static.sh new file mode 100755 index 0000000..66c2c89 --- /dev/null +++ b/scripts/self-hosted-docs/rollback-docs-static.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$(id -u)" -eq 0 ]; then + echo "Do not run this script as root. Use a dedicated docs user." >&2 + exit 1 +fi + +DOCS_ROOT="${DOCS_ROOT:-/srv/apicostx-docs}" +DOCS_RELEASES_DIR="${DOCS_RELEASES_DIR:-${DOCS_ROOT}/releases}" +DOCS_CURRENT_LINK="${DOCS_CURRENT_LINK:-${DOCS_ROOT}/current}" +TEMP_LINK="${DOCS_CURRENT_LINK}.tmp" + +TARGET_INPUT="${1:-}" + +if [ -z "${TARGET_INPUT}" ]; then + echo "Usage: $0 RELEASE_NAME_OR_ABSOLUTE_PATH" >&2 + exit 1 +fi + +if [[ "${TARGET_INPUT}" = /* ]]; then + TARGET_DIR="${TARGET_INPUT}" +else + TARGET_DIR="${DOCS_RELEASES_DIR}/${TARGET_INPUT}" +fi + +if [ ! -d "${TARGET_DIR}" ]; then + echo "Release directory does not exist: ${TARGET_DIR}" >&2 + exit 1 +fi + +ln -sfn "${TARGET_DIR}" "${TEMP_LINK}" +mv -Tf "${TEMP_LINK}" "${DOCS_CURRENT_LINK}" + +echo "Rolled back current docs release to: ${TARGET_DIR}" diff --git a/scripts/self-hosted-docs/verify-docs-static.sh b/scripts/self-hosted-docs/verify-docs-static.sh new file mode 100755 index 0000000..ebbb975 --- /dev/null +++ b/scripts/self-hosted-docs/verify-docs-static.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_DIR="${1:-}" + +if [ -z "${TARGET_DIR}" ]; then + echo "Usage: $0 /path/to/release" >&2 + exit 1 +fi + +if [ ! -d "${TARGET_DIR}" ]; then + echo "Release directory does not exist: ${TARGET_DIR}" >&2 + exit 1 +fi + +if [ ! -f "${TARGET_DIR}/index.html" ]; then + echo "Missing index.html in ${TARGET_DIR}" >&2 + exit 1 +fi + +if [ ! -d "${TARGET_DIR}/assets" ]; then + echo "Missing assets directory in ${TARGET_DIR}" >&2 + exit 1 +fi + +for blocked in .git .github website docs node_modules package.json package-lock.json tsconfig.json; do + if [ -e "${TARGET_DIR}/${blocked}" ]; then + echo "Blocked file or directory found in release: ${blocked}" >&2 + exit 1 + fi +done + +if find "${TARGET_DIR}" -type d \( -name .git -o -name .github \) | grep -q .; then + echo "Nested git metadata found inside the release." >&2 + exit 1 +fi + +if find "${TARGET_DIR}" -perm -002 | grep -q .; then + echo "World-writable files found in the release." >&2 + exit 1 +fi + +echo "Verified static docs release: ${TARGET_DIR}" diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 2b02e86..aea2295 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -1,13 +1,12 @@ import type {Config} from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; -const repository = process.env.GITHUB_REPOSITORY ?? 'your-org/your-repo'; +const repository = process.env.GITHUB_REPOSITORY ?? 'morganross/apicostx-docs'; const [organizationName, projectName] = repository.split('/'); -const isUserOrOrgPagesSite = projectName === `${organizationName}.github.io`; -const url = process.env.DOCS_SITE_URL ?? `https://${organizationName}.github.io`; -const baseUrl = - process.env.DOCS_BASE_URL ?? - (isUserOrOrgPagesSite ? '/' : `/${projectName}/`); +const defaultSiteUrl = 'https://apicostx.com'; +const defaultBaseUrl = '/docs-static/'; +const url = process.env.DOCS_SITE_URL ?? defaultSiteUrl; +const baseUrl = process.env.DOCS_BASE_URL ?? defaultBaseUrl; const config: Config = { title: 'ACM Documentation',