Deploy Docs #148
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Docs | |
| # The deploy, and the verification layer around it (#269). | |
| # | |
| # ## Why this file no longer has a `push:` trigger | |
| # | |
| # It used to hang off `push: branches: [main]` exactly as `ci.yml` does, which | |
| # meant the two ran in PARALLEL: a commit that failed `build`, `Node floor`, | |
| # the locale surface or any translation gate still deployed. There was no | |
| # `needs:`, no `workflow_run`, nothing. That is defect 1 of #269. | |
| # | |
| # The fix is that this workflow is now CALLED by `ci.yml` as a job with | |
| # `needs: [node-floor, build]`, so it cannot start until those are green, and | |
| # `ci.yml` restricts it to a push on `main`. `workflow_run` was the alternative | |
| # and was rejected: it fires on a failed run too, so the conclusion has to be | |
| # re-checked by hand inside the workflow, and it runs detached from the run | |
| # whose artifact it is meant to publish — which would have made defect 2 below | |
| # unfixable without re-downloading across runs. | |
| # | |
| # `workflow_dispatch` survives, but it can no longer deploy. It runs the smoke | |
| # check against whatever is live, which is the one thing a human wants on | |
| # demand; publishing outside the CI gate is exactly the hole this card closes. | |
| # | |
| # ## What the jobs are for | |
| # | |
| # deploy publishes the bundle CI built and tested (defect 2), and refuses | |
| # to call it a success without a NEW version id serving (defect 4) | |
| # smoke asks the live site whether it renders (defect 3) | |
| # rollback reuses rollback-docs.yml (#267) when the smoke check goes red | |
| # report files or updates ONE issue on any failure, because this lane reads | |
| # the board and not the run list — 36 red runs over 10 days produced | |
| # no card at all | |
| # | |
| # ⚠️ Every deploy is currently REJECTED: `main` builds a Worker over | |
| # Cloudflare's 64 MiB limit (#261), so `deploy` is expected to go red and the | |
| # serving version cannot be displaced. `smoke` still runs — it is checking the | |
| # live site, not this run's output — which is why it is not gated on the deploy | |
| # succeeding. | |
| on: | |
| workflow_call: | |
| inputs: | |
| artifact_name: | |
| description: 'Name of the artifact holding the .open-next bundle CI built and tested.' | |
| required: true | |
| type: string | |
| base_url: | |
| description: 'Origin the smoke check runs against.' | |
| required: false | |
| type: string | |
| default: 'https://docs.objectos.ai' | |
| file_issue: | |
| description: 'File or update the deploy-failure card when something goes red.' | |
| required: false | |
| type: boolean | |
| default: true | |
| workflow_dispatch: | |
| inputs: | |
| # Declared so that `inputs.artifact_name` is a defined property under both | |
| # triggers rather than one. It is deliberately unusable from here: a | |
| # manual run has no artifact from a CI run to publish, and the `deploy` | |
| # job below refuses to start on a `workflow_dispatch` event regardless of | |
| # what this says. Publishing outside the CI gate is the hole #269 closes. | |
| artifact_name: | |
| description: 'Leave blank. A manual run cannot deploy; it only smoke-checks whatever is live.' | |
| required: false | |
| type: string | |
| default: '' | |
| base_url: | |
| description: 'Origin to smoke-check. Defaults to the live docs site.' | |
| required: false | |
| type: string | |
| default: 'https://docs.objectos.ai' | |
| paths: | |
| description: 'Comma-separated paths to check INSTEAD of the built-in target list. Use this to point the check at something that does not render and watch it go red.' | |
| required: false | |
| type: string | |
| default: '' | |
| file_issue: | |
| description: 'File or update the deploy-failure card if this run goes red. Off by default: a manual run is usually an experiment.' | |
| required: false | |
| type: boolean | |
| default: false | |
| verify_plumbing: | |
| description: 'TEMPORARY (removed before merge). Prove GITHUB_TOKEN can dispatch rollback-docs.yml at all.' | |
| required: false | |
| type: boolean | |
| default: false | |
| jobs: | |
| deploy: | |
| name: Publish the tested Worker | |
| # Only when a caller handed us a bundle. A manual dispatch has no | |
| # `artifact_name` and therefore cannot deploy — see the header. | |
| if: github.event_name != 'workflow_dispatch' && inputs.artifact_name != '' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: cloudflare-docs | |
| url: https://docs-objectos.objectstack.workers.dev | |
| permissions: | |
| contents: read | |
| outputs: | |
| previous_version_id: ${{ steps.verdict.outputs.previous_version_id }} | |
| new_version_id: ${{ steps.verdict.outputs.new_version_id }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: pnpm/action-setup@v6 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| cache: pnpm | |
| - run: pnpm install --frozen-lockfile | |
| # Defect 2 of #269: the deploy used to run `opennextjs-cloudflare build` | |
| # itself, so CI built the site, threw it away, and the deploy published a | |
| # DIFFERENT build that nothing had checked. This downloads the bundle the | |
| # `build` job produced from the `.next` output its own gates measured. | |
| # `opennextjs-cloudflare deploy` does not build; it uploads what is here. | |
| - name: Download the Worker CI built and tested | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ inputs.artifact_name }} | |
| path: apps/docs/.open-next | |
| # Taken BEFORE the deploy and required to succeed: it is both half of the | |
| # "did anything actually change" comparison and the rollback target. A | |
| # deploy with no known-good version to fall back to is not one this | |
| # pipeline should start. | |
| - name: Read the version that is serving now | |
| working-directory: apps/docs | |
| shell: bash | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| run: | | |
| pnpm exec wrangler deployments status --name docs-objectos --json \ | |
| > "$RUNNER_TEMP/before.json" | |
| cat "$RUNNER_TEMP/before.json" | |
| # `set +e` is the point of this step, not an oversight. #269: "Do not | |
| # treat a step's exit code as evidence the deploy worked." A rejected | |
| # upload and an accepted one are not reliably distinguishable from out | |
| # here, so the exit code is captured as EVIDENCE and handed to the gate | |
| # below, which is the only thing allowed to call this a success. | |
| - name: Deploy to Cloudflare | |
| id: deploy | |
| working-directory: apps/docs | |
| shell: bash | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| run: | | |
| set +e | |
| pnpm exec opennextjs-cloudflare deploy > "$RUNNER_TEMP/deploy.log" 2>&1 | |
| DEPLOY_EXIT=$? | |
| set -e | |
| cat "$RUNNER_TEMP/deploy.log" | |
| echo "exit_code=$DEPLOY_EXIT" >> "$GITHUB_OUTPUT" | |
| echo "::notice::deploy command exited ${DEPLOY_EXIT} — the verdict is the next step, not this number" | |
| - name: Read what is serving afterwards | |
| if: always() | |
| working-directory: apps/docs | |
| shell: bash | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| run: | | |
| set +e | |
| pnpm exec wrangler deployments status --name docs-objectos --json \ | |
| > "$RUNNER_TEMP/after.json" 2>"$RUNNER_TEMP/after.err" | |
| set -e | |
| cat "$RUNNER_TEMP/after.json" "$RUNNER_TEMP/after.err" | |
| # The arbiter. Requires a new, well-formed version id that was not | |
| # already serving and that the post-deploy reading agrees is serving now. | |
| # An unreadable input is a finding here, never a skip. | |
| # | |
| # `shell: bash` is load-bearing, not tidiness: the default shell for a | |
| # `run:` step is `bash -e {0}` with no pipefail, so in `node ... | tee` | |
| # the step takes tee's status and a gate that exits 1 passes silently. | |
| - name: Assert a new version is serving | |
| id: verdict | |
| if: always() | |
| shell: bash | |
| run: | | |
| node .github/scripts/check-deploy-version.mjs \ | |
| --before "$RUNNER_TEMP/before.json" \ | |
| --after "$RUNNER_TEMP/after.json" \ | |
| --deploy-log "$RUNNER_TEMP/deploy.log" \ | |
| --deploy-exit "${{ steps.deploy.outputs.exit_code || '1' }}" \ | |
| | tee -a "$GITHUB_STEP_SUMMARY" | |
| smoke: | |
| name: Smoke-check the live site | |
| needs: [deploy] | |
| # Runs whether or not the deploy published anything, and that is deliberate | |
| # while #261 is open: today every deploy is rejected, so gating this on a | |
| # successful deploy would mean the site is never checked at all. The | |
| # rollback below is what is gated on the deploy having succeeded. | |
| if: always() && needs.deploy.result != 'cancelled' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| # Zero-dependency on purpose: no install, so this stays a seconds-long | |
| # job and can be run by hand against any origin. | |
| # | |
| # Every run also fetches a path that must NOT render and requires it to | |
| # produce findings — see the script's header. A green here therefore | |
| # carries a live demonstration that the same code path can go red, which | |
| # is the only thing that makes it worth reading. | |
| - name: Smoke | |
| shell: bash | |
| env: | |
| BASE_URL: ${{ inputs.base_url }} | |
| SMOKE_PATHS: ${{ inputs.paths }} | |
| run: | | |
| ARGS=(--base "$BASE_URL") | |
| if [ -n "$SMOKE_PATHS" ]; then | |
| ARGS+=(--paths "$SMOKE_PATHS") | |
| echo "::warning::running against an ad-hoc path list: $SMOKE_PATHS" | |
| fi | |
| node .github/scripts/smoke-docs.mjs "${ARGS[@]}" | tee -a "$GITHUB_STEP_SUMMARY" | |
| rollback: | |
| name: Roll back the bad version | |
| needs: [deploy, smoke] | |
| # Only when THIS run published something and the site then failed to | |
| # render. A failed deploy displaces nothing, so there is nothing to undo. | |
| if: >- | |
| always() | |
| && needs.deploy.result == 'success' | |
| && needs.smoke.result == 'failure' | |
| && needs.deploy.outputs.previous_version_id != '' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| actions: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # Reusing rollback-docs.yml (#267) rather than reimplementing the | |
| # Cloudflare call: it already validates the version id, already draws the | |
| # same `cloudflare-docs` credentials, and a required-reviewer rule added | |
| # to that environment (#266) is meant to gate this path too. | |
| - name: Dispatch rollback-docs.yml | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| VERSION_ID: ${{ needs.deploy.outputs.previous_version_id }} | |
| BAD_VERSION: ${{ needs.deploy.outputs.new_version_id }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| gh workflow run rollback-docs.yml --ref "$DEFAULT_BRANCH" \ | |
| -f version_id="$VERSION_ID" \ | |
| -f reason="automatic rollback: smoke check failed after $BAD_VERSION ($RUN_URL)" | |
| echo "::notice::rollback to $VERSION_ID dispatched" | |
| report: | |
| name: File or update the failure card | |
| needs: [deploy, smoke, rollback] | |
| if: >- | |
| always() | |
| && inputs.file_issue | |
| && (needs.deploy.result == 'failure' | |
| || needs.smoke.result == 'failure' | |
| || needs.rollback.result == 'failure') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| issues: write | |
| steps: | |
| # Defect 4 of #269: 36 consecutive red deploys across two PM tenures | |
| # produced no card. This lane reads the board every round and does not | |
| # read the run list, so a red run is not a signal — a card is. | |
| # | |
| # ONE card, updated. `gh issue list --label` finds the open one; a new | |
| # card is only created when a human has closed the last one, which is the | |
| # correct reading of "this is fixed, tell me if it comes back". | |
| - name: File or update | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| DEPLOY_RESULT: ${{ needs.deploy.result }} | |
| SMOKE_RESULT: ${{ needs.smoke.result }} | |
| ROLLBACK_RESULT: ${{ needs.rollback.result }} | |
| NEW_VERSION: ${{ needs.deploy.outputs.new_version_id }} | |
| PREV_VERSION: ${{ needs.deploy.outputs.previous_version_id }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| SHA: ${{ github.sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| LABEL='deploy-failure' | |
| TITLE='The docs deploy pipeline is failing' | |
| gh label create "$LABEL" \ | |
| --color 'B60205' \ | |
| --description 'Automated: the docs deploy or its post-deploy smoke check failed' \ | |
| --force | |
| NUM="$(gh issue list --label "$LABEL" --state open --limit 1 --json number --jq '.[0].number // empty')" | |
| BODY="$(cat <<EOF | |
| Automated report from \`ci.yml\` -> \`deploy-docs.yml\`. | |
| | | | | |
| |:--|:--| | |
| | run | $RUN_URL | | |
| | commit | \`$SHA\` | | |
| | deploy | \`$DEPLOY_RESULT\` | | |
| | smoke | \`$SMOKE_RESULT\` | | |
| | rollback | \`$ROLLBACK_RESULT\` | | |
| | version serving before | \`${PREV_VERSION:-unknown}\` | | |
| | version published | \`${NEW_VERSION:-none}\` | | |
| A \`deploy\` failure with no published version usually means Cloudflare | |
| refused the upload — while #261 is open that is the expected steady | |
| state and the live site is unaffected. A \`smoke\` failure means the | |
| live site did not render; the run log names which rule fired. | |
| EOF | |
| )" | |
| if [ -n "$NUM" ]; then | |
| gh issue comment "$NUM" --body "$BODY" | |
| echo "::notice::updated existing card #$NUM" | |
| else | |
| URL="$(gh issue create --title "$TITLE" --label "$LABEL" --body "$BODY")" | |
| echo "::notice::filed $URL" | |
| fi | |
| # TEMPORARY — removed before this PR merges. | |
| # | |
| # The `rollback` job above is the one part of this pipeline that only ever | |
| # runs during an outage, and it depends on something not obvious: whether | |
| # GITHUB_TOKEN is allowed to dispatch a workflow at all. GitHub suppresses | |
| # workflow triggering from token-driven events in several cases; if that | |
| # applied here the automatic rollback would silently never happen, which is | |
| # the exact failure class this card exists to eliminate. | |
| # | |
| # So: run the same command, with the same token and the same permission, and | |
| # confirm from the API that a run was really created. `rollback-docs.yml` | |
| # with a blank `version_id` lists versions and changes nothing, so this is | |
| # safe to fire against production. | |
| verify-plumbing: | |
| name: TEMPORARY - prove the rollback dispatch fires | |
| if: inputs.verify_plumbing | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| actions: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Dispatch rollback-docs.yml with a blank version_id | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| LATEST='repos/'"$GH_REPO"'/actions/workflows/rollback-docs.yml/runs?per_page=1' | |
| BEFORE="$(gh api "$LATEST" --jq '.workflow_runs[0].id // 0')" | |
| echo "latest rollback run before: $BEFORE" | |
| gh workflow run rollback-docs.yml --ref "$DEFAULT_BRANCH" \ | |
| -f version_id= \ | |
| -f reason="plumbing check from the #269 branch: lists versions, changes nothing" | |
| AFTER="$BEFORE" | |
| for _ in $(seq 1 24); do | |
| sleep 5 | |
| AFTER="$(gh api "$LATEST" --jq '.workflow_runs[0].id // 0')" | |
| [ "$AFTER" != "$BEFORE" ] && break | |
| done | |
| if [ "$AFTER" = "$BEFORE" ]; then | |
| echo "::error::GITHUB_TOKEN did NOT create a rollback-docs run — the automatic rollback would silently never fire" | |
| exit 1 | |
| fi | |
| echo "::notice::rollback-docs.yml run $AFTER was created by GITHUB_TOKEN" |