summaryrefslogtreecommitdiff
path: root/.github/workflows/docs-preview-deploy.yml
blob: 3e3de0d36ecabd3d0cb5a4672d810468fc2c7107 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
name: Docs preview deploy
on:
  workflow_run:
    workflows: ["Docs preview build"]
    types: [completed]
permissions:
  contents: read
  pull-requests: write
  issues: write        # label removal + PR comment go through the issues API
  statuses: read
  actions: read        # cross-run artifact download
jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # BASE repo code only — never PR head (§10)
      - uses: actions/download-artifact@v4
        with:
          name: docs-preview
          path: out
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - name: Authorization gate (approval record + SHA match, §10)
        id: gate
        env: { GH_TOKEN: "${{ github.token }}" }
        run: |
          set -eu
          # TRUST MODEL (§10): out/PR_NUMBER + out/HEAD_SHA come from the UNPRIVILEGED
          # run and are attacker-controlled. Trusted anchors: the workflow_run event's
          # head_sha, and the approval record (commit status) written by the privileged
          # approve workflow. The artifact is accepted only when all three agree.
          event_sha='${{ github.event.workflow_run.head_sha }}'
          art_sha=$(cat out/HEAD_SHA)
          [ "$art_sha" = "$event_sha" ] || { echo "::error::artifact SHA ($art_sha) != workflow_run head_sha ($event_sha)"; exit 1; }
          # Validate the artifact's claimed PR number BEFORE it's used anywhere — it comes
          # from the unprivileged run and must be a bare digit string.
          art_pr=$(cat out/PR_NUMBER)
          case "$art_pr" in
            ''|*[!0-9]*) echo "::error::artifact PR_NUMBER is not a bare digit string: '$art_pr'"; exit 1 ;;
          esac
          # Resolve EVERY PR associated with the trusted head SHA — a single commit can be
          # the head of more than one open PR, so picking just .[0].number is not reliable.
          # Never trust out/PR_NUMBER for authorization on its own: require it to be a
          # MEMBER of this trusted list. The membership check happens in bash (below), not
          # inside a jq filter, so the untrusted artifact value is never interpolated into
          # jq code.
          pr_list=$(gh api "repos/${{ github.repository }}/commits/$event_sha/pulls" --jq '.[].number')
          [ -n "$pr_list" ] || { echo "no open PR for $event_sha"; exit 78; }
          pr=""
          while IFS= read -r candidate; do
            if [ "$candidate" = "$art_pr" ]; then
              pr="$candidate"
              break
            fi
          done <<< "$pr_list"
          [ -n "$pr" ] || { echo "::error::artifact PR number ($art_pr) is not among the trusted PRs for $event_sha ($pr_list)"; exit 1; }
          state=$(gh pr view "$pr" --repo '${{ github.repository }}' --json state,labels)
          echo "$state" | jq -e '.state == "OPEN"' >/dev/null || { echo "PR closed"; exit 78; }
          echo "$state" | jq -e '.labels[].name | select(. == "preview")' >/dev/null || { echo "no preview label"; exit 78; }
          gh api "repos/${{ github.repository }}/commits/$event_sha/statuses" \
            --jq '.[] | select(.context=="docs-preview-approved" and .state=="success")' | grep -q . \
            || { echo "::error::no approval record on $event_sha — artifact predates/postdates labeling"; exit 1; }
          echo "pr=$pr" >> "$GITHUB_OUTPUT"
      - name: Upload to R2 prefix (bulk, S3 API — §10 MIME via content-type detection)
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: auto
          R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
        run: |
          set -eu
          pr='${{ steps.gate.outputs.pr }}'
          rm -f out/PR_NUMBER out/HEAD_SHA
          # aws s3 sync = parallel bulk upload + per-file Content-Type guessing.
          # Sequential per-file `wrangler r2 object put` was rejected: one Node
          # process per file × thousands of files ≈ runner-timeout territory.
          aws s3 sync out "s3://vyos-docs-previews/pr-$pr/" \
            --endpoint-url "$R2_ENDPOINT" --delete --only-show-errors
      - name: Consume label + comment URL (§10)
        env: { GH_TOKEN: "${{ github.token }}" }
        run: |
          pr='${{ steps.gate.outputs.pr }}'
          gh pr edit "$pr" --remove-label preview --repo '${{ github.repository }}' || true
          gh pr comment "$pr" --repo '${{ github.repository }}' \
            --body "📖 Docs preview: https://docs-preview.vyos.io/pr-$pr/en/rolling/ (label consumed — re-apply \`preview\` to redeploy after new pushes)"