| 1 | name: Deploy Hugo site |
| 2 | |
| 3 | on: # zizmor: ignore[dangerous-triggers] intentional post-crawl auto-deploy; build job is guarded by conclusion==success and actor checks |
| 4 | push: |
| 5 | branches: |
| 6 | - main |
| 7 | # Auto-deploy after a successful crawl-and-publish run so the site always |
| 8 | # reflects the latest content committed to the publish branch. |
| 9 | workflow_run: |
| 10 | workflows: ["Crawl and publish weekly data"] |
| 11 | types: [completed] |
| 12 | branches: [main] |
| 13 | workflow_dispatch: |
| 14 | |
| 15 | permissions: |
| 16 | contents: read |
| 17 | |
| 18 | concurrency: |
| 19 | group: pages |
| 20 | cancel-in-progress: true |
| 21 | |
| 22 | jobs: |
| 23 | build: |
| 24 | # Allow manual triggers, human pushes to main, and post-crawl auto-runs. |
| 25 | # Bot pushes are excluded from push events to avoid loops; workflow_run |
| 26 | # covers the bot-written publish branch updates. |
| 27 | if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') }} |
| 28 | runs-on: ubuntu-latest |
| 29 | permissions: |
| 30 | contents: read |
| 31 | pages: write |
| 32 | env: |
| 33 | HUGO_VERSION: 0.161.1 |
| 34 | HUGO_PARAMS_GA_MEASUREMENT_ID: ${{ secrets.GA_MEASUREMENT_ID }} |
| 35 | steps: |
| 36 | - name: Check out repository |
| 37 | uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 |
| 38 | with: |
| 39 | fetch-depth: 0 |
| 40 | submodules: recursive |
| 41 | persist-credentials: false |
| 42 | |
| 43 | - name: Configure GitHub Pages |
| 44 | uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 |
| 45 | |
| 46 | - name: Set up Node.js |
| 47 | uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 |
| 48 | with: |
| 49 | node-version: '24' |
| 50 | |
| 51 | - name: Install Hugo |
| 52 | run: | # zizmor: ignore[github-env] writes a fixed runner path (${HOME}/.local/hugo) to GITHUB_PATH, not attacker input; needed so later steps can run hugo |
| 53 | set -euo pipefail |
| 54 | RELEASE_URL="https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}" |
| 55 | TARBALL="hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" |
| 56 | CHECKSUM_FILE="hugo_${HUGO_VERSION}_checksums.txt" |
| 57 | curl --fail --silent --show-error --location --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ |
| 58 | --output "${TARBALL}" "${RELEASE_URL}/${TARBALL}" |
| 59 | |
| 60 | # Download and verify checksums |
| 61 | curl --fail --silent --show-error --location --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ |
| 62 | --output "${CHECKSUM_FILE}" "${RELEASE_URL}/${CHECKSUM_FILE}" |
| 63 | checksum_line="$(awk -v file="${TARBALL}" '$NF == file {print; found=1} END {if (!found) exit 1}' "${CHECKSUM_FILE}")" || { |
| 64 | echo "Error: No checksum entry for ${TARBALL} found in ${CHECKSUM_FILE}" >&2 |
| 65 | exit 1 |
| 66 | } |
| 67 | if ! printf '%s\n' "${checksum_line}" | sha256sum --check; then |
| 68 | echo "Error: Checksum verification failed for ${TARBALL}" >&2 |
| 69 | exit 1 |
| 70 | fi |
| 71 | rm "${CHECKSUM_FILE}" |
| 72 | |
| 73 | mkdir -p "${HOME}/.local/hugo" |
| 74 | tar -C "${HOME}/.local/hugo" -xf "${TARBALL}" |
| 75 | rm "${TARBALL}" |
| 76 | echo "${HOME}/.local/hugo" >> "${GITHUB_PATH}" |
| 77 | export PATH="${HOME}/.local/hugo:${PATH}" |
| 78 | hugo version |
| 79 | |
| 80 | - name: Hydrate generated content from publish |
| 81 | # Architecture: code/theme/config come from main; all generated content |
| 82 | # (weekly pages, rollups, raw data) comes from the canonical publish branch. |
| 83 | # This prevents the site from ever diverging from the crawl-and-publish output. |
| 84 | run: | |
| 85 | set -euo pipefail |
| 86 | git fetch origin publish |
| 87 | # Wipe and re-checkout to mirror publish exactly (deletions propagate) |
| 88 | rm -rf content/weekly content/monthly content/yearly data/analyzed data/raw data/metrics data/snapshots |
| 89 | git checkout origin/publish -- \ |
| 90 | content/weekly/ \ |
| 91 | content/monthly/ \ |
| 92 | content/yearly/ \ |
| 93 | data/analyzed/ \ |
| 94 | data/raw/ \ |
| 95 | data/metrics/ \ |
| 96 | data/snapshots/ || true |
| 97 | echo "Hydrated from publish:" |
| 98 | ls content/weekly/2026/ 2>/dev/null || true |
| 99 | |
| 100 | - name: Build site |
| 101 | run: hugo --minify |
| 102 | |
| 103 | - name: Build Pagefind index |
| 104 | run: npx pagefind --site public/ |
| 105 | |
| 106 | - name: Upload Pages artifact |
| 107 | uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 |
| 108 | with: |
| 109 | path: ./public |
| 110 | |
| 111 | deploy: |
| 112 | needs: build |
| 113 | runs-on: ubuntu-latest |
| 114 | permissions: |
| 115 | pages: write |
| 116 | id-token: write |
| 117 | environment: |
| 118 | name: github-pages |
| 119 | url: ${{ steps.deployment.outputs.page_url }} |
| 120 | steps: |
| 121 | - name: Deploy to GitHub Pages |
| 122 | id: deployment |
| 123 | uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 |
| 124 | |
| 125 | notify-failure: |
| 126 | needs: [build, deploy] |
| 127 | if: failure() |
| 128 | runs-on: ubuntu-latest |
| 129 | permissions: |
| 130 | issues: write |
| 131 | steps: |
| 132 | - name: Create or update failure issue |
| 133 | env: |
| 134 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 135 | GH_REPO: ${{ github.repository }} |
| 136 | SERVER_URL: ${{ github.server_url }} |
| 137 | RUN_ID: ${{ github.run_id }} |
| 138 | run: | |
| 139 | RUN_URL="${SERVER_URL}/${GH_REPO}/actions/runs/${RUN_ID}" |
| 140 | EXISTING=$(gh issue list --label 'deploy-failure' --state open --json number --jq length) |
| 141 | if [ "$EXISTING" -gt 0 ]; then |
| 142 | ISSUE_NUM=$(gh issue list --label 'deploy-failure' --state open --json number --jq '.[0].number') |
| 143 | gh issue comment "$ISSUE_NUM" --body "Deploy failed again: ${RUN_URL}" |
| 144 | echo "Updated existing issue #$ISSUE_NUM" |
| 145 | else |
| 146 | BODY=$(printf '%s\n\n%s\n\n%s\n%s\n%s' \ |
| 147 | "The Deploy Hugo site workflow failed." \ |
| 148 | "**Run:** ${RUN_URL}" \ |
| 149 | "Please triage:" \ |
| 150 | "- If transient (rate limit, network), close with comment" \ |
| 151 | "- If real bug, assign to the right squad member") |
| 152 | gh issue create \ |
| 153 | --title "🔴 Deploy Hugo site failed (run ${RUN_ID})" \ |
| 154 | --label 'squad,deploy-failure' \ |
| 155 | --body "$BODY" |
| 156 | fi |