From fb24110933fcd813274ed6b7bf02585723aef875 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 12 Jun 2026 17:54:32 -0400 Subject: [PATCH] feat(.github/workflows): trigger docs reindex on release.published (DOCS-327) (#26070) Closes [DOCS-327](https://linear.app/codercom/issue/DOCS-327/trigger-docs-reindex-on-codercoder-releasepublished). ## What Add `release: { types: [published] }` to `.github/workflows/deploy-docs.yaml` so that publishing a stable `vX.Y.Z` GitHub Release on this repo auto-dispatches the docs-sync handler against the corresponding `release/X.Y` branch. The existing `push` and `workflow_dispatch` triggers are unchanged. The `Compute action and ref` step gains a release-event branch that: - Skips prereleases (`github.event.release.prerelease == true`) with a workflow notice. - Matches the tag against `^v([0-9]+)\.([0-9]+)\.[0-9]+$` and translates `v2.35.0` to `release/2.35`. - Falls through with a notice and `exit 0` for any tag that doesn't match the plain semver shape (`v2.35`, `v2.35.0-rc.1`, etc.). Downstream validation, HMAC body construction, and the POST step are unchanged. The POST step gains an `if: steps.input.outputs.action != ''` guard so the two `exit 0` paths skip the POST instead of sending empty `action`/`ref` to the production handler. A new `.github/workflows/test-deploy-docs-release.sh` exercises the release-event bash against the 11 event scenarios in the table below plus 3 regex boundary cases, mirroring the existing `test-deploy-docs-diff.sh` pattern. ## Why Today, every mainline rollover requires a human to dispatch this workflow manually with `action=index, ref=release/X.Y`. We just hit this rotation friction on [DOCS-324](https://linear.app/codercom/issue/DOCS-324/rotate-algolia-indexer-allowlist-for-v234-launch-add-release234-drop) (v2.34 launch) and the resulting empty-search-results incident on `/docs/@v2.34.x/...`. `release.published` is the right cue: it fires exactly when a version becomes user-visible, not when its release branch is cut weeks earlier with possibly-incomplete docs. ## Coupling (important) This change is **intentionally inert until coder.com's `INDEXED_REFS_BY_CORPUS` allowlist becomes self-rotating** (filed under [DOCS-210](https://linear.app/codercom/issue/DOCS-210/automated-docs-index-lifecycle-management)). Until that lands, the handler still rejects new minors with `{action: "skipped", reason: "...not in INDEXED_REFS_BY_CORPUS"}` and this workflow logs the skip. Pre-wiring lets both halves land roughly in parallel so the next release cut after both ship is automatic. Reviewers: feel free to merge this independently. There is no downside to the wiring being live before the allowlist half ships; worst case, every release-publish event creates a no-op workflow run. ## Behavior trace (the cases the bash handles)
11 event scenarios I walked through by hand | Event | Tag | prerelease | Result | |---|---|---|---| | push to main | n/a | n/a | `index`, `ref=main` (existing) | | push to release/2.34 | n/a | n/a | `index`, `ref=release/2.34` (existing) | | workflow_dispatch index release/2.34 | n/a | n/a | `index`, `ref=release/2.34` (existing) | | workflow_dispatch delete release/2.31 | n/a | n/a | `delete`, `ref=release/2.31` (existing) | | release.published | `v2.35.0` | `false` | `index`, `ref=release/2.35` (new) | | release.published | `v2.35.0-rc.1` | `true` | notice + `exit 0` (new) | | release.published | `v2.35.0-rc.1` | `false` | notice + `exit 0`, regex miss (new) | | release.published | `v2.35` | `false` | notice + `exit 0`, regex miss (new) | | release.published | `release-2.35` | `false` | notice + `exit 0`, regex miss (new) | | release.published | `v0.0.0` | `false` | `index`, `ref=release/0.0` then handler rejects via allowlist (defense in depth) | | release.published | `` (empty) | unset | notice with `` + `exit 0` |
## Safety - The handler's allowlist gate still applies; this PR can only cause `{action: "skipped"}` responses until DOCS-210's allowlist-derivation lands. No risk of indexing an unintended ref. - The workflow's existing input validation (`case "$REF" in main|release/*)`) rejects any translation output that isn't `release/.`. Defense in depth in case the regex ever loosens by accident. - [DOCS-121](https://linear.app/codercom/issue/DOCS-121/post-mortem-docs-search-outage-2026-05-12-pr-25049-merge-wiped-docs) self-trigger risk is not present here: the new trigger is `release.published`, not push-on-paths. Workflow file edits cannot induce a release event. - `concurrency: { group: deploy-docs-${{ github.ref }} }` already exists. Release events have `github.ref=refs/tags/vX.Y.Z`, distinct from push events on the same release branch. A theoretical race resolves through the handler's atomic deleteBy+saveObjects. - The POST step now has an `if:` guard that skips downstream calls when the Compute step exits early without writing outputs. Closes the empty-env-var failure mode that coder-agents-review CRF-1 flagged. ## Verification - `actionlint .github/workflows/deploy-docs.yaml` clean. - `make pre-commit-light` clean: `fmt/shfmt`, `fmt/markdown`, `lint/actions/actionlint`, `lint/shellcheck`, `lint/markdown`, `lint/emdash`, `lint/typos`, etc. - `.github/workflows/test-deploy-docs-release.sh`: 14 cases pass (11 scenario table + 3 regex boundary cases). - Bash logic hand-traced through 11 event scenarios (table above). ## Out of scope - Build-time allowlist derivation in coder.com (DOCS-210a, will be filed/PR'd as a sibling change). - Webhook-driven cleanup of aged-out refs ([DOCS-210](https://linear.app/codercom/issue/DOCS-210) parent). - code-server release lifecycle (different repo, code-server's docs corpus stays at `main`). --- _Coder Agents on behalf of @nickvigilante._ --- .github/workflows/deploy-docs.yaml | 80 ++++++- .github/workflows/test-deploy-docs-release.sh | 217 ++++++++++++++++++ 2 files changed, 286 insertions(+), 11 deletions(-) create mode 100755 .github/workflows/test-deploy-docs-release.sh diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index abb07d65ad..dd569cdd52 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -1,9 +1,20 @@ name: Update coder.com/docs -# Triggers updates to the public docs at coder.com/docs whenever this -# branch's docs/** content changes. One preflight job (`changes`) feeds -# two parallel sibling jobs so that search records, the static cache, -# and any new routes register at the same time: +# Triggers updates to the public docs at coder.com/docs from three +# sources: +# +# * push to main or release/* (docs/** only): markdown edits land in +# search and ISR within seconds. +# * release.published: when a stable vX.Y.Z release ships on this +# repo, the workflow translates the tag to its release/X.Y branch +# and reindexes. Eliminates the manual workflow_dispatch step from +# the mainline rotation. Prereleases and non-semver tags are +# skipped. See DOCS-327. +# * workflow_dispatch: operator-driven, with explicit action and ref. +# +# One preflight job (`changes`) feeds two parallel sibling jobs so that +# search records, the static cache, and any new routes register at the +# same time: # # 1. algolia-and-isr: HMAC-signed POST to coder.com/api/algolia-docs-sync. # The handler re-extracts records for the (corpus, ref) pair and @@ -35,6 +46,13 @@ on: # auto-trigger a production reindex; use workflow_dispatch instead. # See DOCS-121 (incident) and DOCS-124 (fix). - "docs/**" + release: + # Fires when a draft release is published, when a release goes from + # prerelease to non-prerelease, or when a release is created already + # published. The Compute step below translates the published tag + # (vX.Y.Z) into its release/X.Y branch and skips prereleases. See + # DOCS-327 for the rotation context that motivated this trigger. + types: [published] workflow_dispatch: inputs: action: @@ -75,7 +93,8 @@ jobs: # paths_json: a JSON array of {path, status} objects, or "[]" # when no markdown changes are eligible for # surgical mode (manifest-only push, an - # uncomputable diff, a workflow_dispatch trigger, + # uncomputable diff, a non-push event + # (workflow_dispatch or release.published), # or a diff that exceeds the surgical-mode cap). # An empty array tells the handler to fall back # to whole-branch reindex. @@ -100,10 +119,11 @@ jobs: # everything for this (corpus, ref)". echo "paths_json=[]" >> "$GITHUB_OUTPUT" } - # workflow_dispatch never has a diff range; treat as - # "manifest unchanged" so the manual reindex/delete path - # doesn't trigger a Vercel rebuild it didn't ask for, and as - # whole-branch so a manual reindex is exhaustive. + # Non-push events (workflow_dispatch, release.published) + # have no diff range; treat as "manifest unchanged" so the + # manual or release-triggered reindex doesn't fire a Vercel + # rebuild it didn't ask for, and as whole-branch so the + # resulting reindex is exhaustive. if [ "$EVENT_NAME" != "push" ]; then echo "manifest_changed=false" >> "$GITHUB_OUTPUT" emit_whole_branch_fallback @@ -247,10 +267,40 @@ jobs: INPUT_ACTION: ${{ inputs.action }} INPUT_REF: ${{ inputs.ref }} GITHUB_REF_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} run: | set -euo pipefail - ACTION="${INPUT_ACTION:-index}" - REF="${INPUT_REF:-$GITHUB_REF_NAME}" + ACTION="" + REF="" + # release.published path: translate a stable vX.Y.Z tag into + # its release/X.Y branch and let the rest of the step + # validate. Skip prereleases and any tag that does not match + # the plain semver shape; backports (vX.Y.) are + # in-scope because they may carry doc updates worth + # reindexing. See DOCS-327. The handler's allowlist gates the + # downstream POST, so an unsupported minor still no-ops + # rather than reindexing something we did not intend. + # + # Tested in test-deploy-docs-release.sh. Keep that script in + # sync with any changes to this block. + if [ "${EVENT_NAME:-}" = "release" ]; then + if [ "${RELEASE_PRERELEASE:-false}" = "true" ]; then + echo "::notice::Skipping prerelease ${RELEASE_TAG:-}; no docs reindex." + exit 0 + fi + if [[ "${RELEASE_TAG:-}" =~ ^v([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then + ACTION="index" + REF="release/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + echo "::notice::Release ${RELEASE_TAG} resolved to ref ${REF}." + else + echo "::notice::Skipping ${RELEASE_TAG:-}: not a plain vX.Y.Z release tag." + exit 0 + fi + fi + ACTION="${ACTION:-${INPUT_ACTION:-index}}" + REF="${REF:-${INPUT_REF:-$GITHUB_REF_NAME}}" # Reject newlines/carriage returns in either input. GitHub # Actions parses GITHUB_OUTPUT line-by-line with last-writer- # wins, so a newline in $REF would let an operator dispatch @@ -303,6 +353,14 @@ jobs: echo "ref=$REF" >> "$GITHUB_OUTPUT" - name: POST to coder.com docs indexer + # Sentinel guard. The Compute step has two release-event + # early-exit paths (prerelease skip, non-semver tag skip) that + # succeed without writing action/ref to GITHUB_OUTPUT. Without + # this guard, the POST would still fire with empty ACTION and + # REF env vars, sending stray no-op traffic to the production + # handler. The step only writes `action` on the success path, + # so its presence is a reliable proceed signal. See DOCS-327. + if: steps.input.outputs.action != '' env: ACTION: ${{ steps.input.outputs.action }} REF: ${{ steps.input.outputs.ref }} diff --git a/.github/workflows/test-deploy-docs-release.sh b/.github/workflows/test-deploy-docs-release.sh new file mode 100755 index 0000000000..2dc2716a27 --- /dev/null +++ b/.github/workflows/test-deploy-docs-release.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Regression tests for the release.published branch in the "Compute +# action and ref" step of deploy-docs.yaml. The workflow translates a +# stable vX.Y.Z release tag into its release/X.Y branch and skips +# prereleases or non-semver tags. This script exercises that bash +# block against the documented event sources (push, workflow_dispatch, +# release.published) plus regex boundary cases so we can catch +# regressions in the regex, the prerelease gate, or either early-exit +# path without spinning up the full workflow. +# +# Keep compute_action_ref below in sync with deploy-docs.yaml. The +# workflow comment "Tested in test-deploy-docs-release.sh" is the +# contract. + +set -euo pipefail + +# compute_action_ref runs the workflow's release-event logic in a +# subshell so its `exit 0` only ends one invocation. Reads EVENT_NAME, +# RELEASE_TAG, RELEASE_PRERELEASE, INPUT_ACTION, INPUT_REF, and +# GITHUB_REF_NAME from the environment and prints lines compatible +# with the tests below: +# * release skip: stdout has the `::notice::` line, no ACTION/REF. +# * release accept: stdout has ACTION=, REF=, and the `::notice::` +# line, in the same order as the workflow. +# * push/workflow_dispatch: stdout has ACTION= and REF= only. +# +# This duplicates the workflow block byte-for-byte. Update both +# together; the assertions below describe the contract. +compute_action_ref() { + ( + set -u + ACTION="" + REF="" + if [ "${EVENT_NAME:-}" = "release" ]; then + if [ "${RELEASE_PRERELEASE:-false}" = "true" ]; then + echo "::notice::Skipping prerelease ${RELEASE_TAG:-}; no docs reindex." + exit 0 + fi + if [[ "${RELEASE_TAG:-}" =~ ^v([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then + ACTION="index" + REF="release/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + echo "::notice::Release ${RELEASE_TAG} resolved to ref ${REF}." + else + echo "::notice::Skipping ${RELEASE_TAG:-}: not a plain vX.Y.Z release tag." + exit 0 + fi + fi + ACTION="${ACTION:-${INPUT_ACTION:-index}}" + REF="${REF:-${INPUT_REF:-$GITHUB_REF_NAME}}" + echo "ACTION=$ACTION" + echo "REF=$REF" + ) +} + +failures=0 +section="" + +start_section() { + section="$1" + echo + echo "--- $section ---" +} + +# run_case clears the relevant env vars and runs the function with the +# values from the scenario. Captures stdout into a string the test can +# assert against. Unset vars use the function's :- defaults so the +# tests exercise the same fallbacks the workflow does. +run_case() { + local event_name="$1" + local release_tag="$2" + local release_prerelease="$3" + local input_action="$4" + local input_ref="$5" + local github_ref_name="$6" + EVENT_NAME="$event_name" \ + RELEASE_TAG="$release_tag" \ + RELEASE_PRERELEASE="$release_prerelease" \ + INPUT_ACTION="$input_action" \ + INPUT_REF="$input_ref" \ + GITHUB_REF_NAME="$github_ref_name" \ + compute_action_ref +} + +# assert_equals checks the captured output against the expected lines +# joined by literal newlines. Quoting prevents shell expansion of `*` +# or `$` inside the expected payload. +assert_equals() { + local description="$1" + local actual="$2" + local expected="$3" + if [ "$actual" = "$expected" ]; then + printf 'ok %s\n' "$description" + else + printf 'FAIL %s\n' "$description" + printf ' expected:\n' + printf '%s\n' "$expected" | sed 's/^/ /' + printf ' actual:\n' + printf '%s\n' "$actual" | sed 's/^/ /' + failures=$((failures + 1)) + fi +} + +# Each scenario names its event source so a future reader can match a +# test to the workflow path it exercises without reading the bash. + +# --------------------------------------------------------------- +start_section "push event (existing behavior)" +# --------------------------------------------------------------- + +actual=$(run_case "push" "" "" "" "" "main") +assert_equals "push to main keeps ACTION=index, REF=main" \ + "$actual" \ + $'ACTION=index\nREF=main' + +actual=$(run_case "push" "" "" "" "" "release/2.34") +assert_equals "push to release/2.34 keeps ACTION=index, REF=release/2.34" \ + "$actual" \ + $'ACTION=index\nREF=release/2.34' + +# --------------------------------------------------------------- +start_section "workflow_dispatch event (existing behavior)" +# --------------------------------------------------------------- + +actual=$(run_case "workflow_dispatch" "" "" "index" "release/2.34" "main") +assert_equals "workflow_dispatch index release/2.34 honors inputs" \ + "$actual" \ + $'ACTION=index\nREF=release/2.34' + +actual=$(run_case "workflow_dispatch" "" "" "delete" "release/2.31" "main") +assert_equals "workflow_dispatch delete release/2.31 honors inputs" \ + "$actual" \ + $'ACTION=delete\nREF=release/2.31' + +# --------------------------------------------------------------- +start_section "release.published event (new in DOCS-327)" +# --------------------------------------------------------------- + +actual=$(run_case "release" "v2.35.0" "false" "" "" "") +assert_equals "stable v2.35.0 resolves to release/2.35" \ + "$actual" \ + $'::notice::Release v2.35.0 resolved to ref release/2.35.\nACTION=index\nREF=release/2.35' + +actual=$(run_case "release" "v2.35.0-rc.1" "true" "" "" "") +assert_equals "marked prerelease v2.35.0-rc.1 is skipped, no ACTION/REF" \ + "$actual" \ + '::notice::Skipping prerelease v2.35.0-rc.1; no docs reindex.' + +actual=$(run_case "release" "v2.35.0-rc.1" "false" "" "" "") +assert_equals "rc tag without prerelease flag fails regex and is skipped" \ + "$actual" \ + '::notice::Skipping v2.35.0-rc.1: not a plain vX.Y.Z release tag.' + +actual=$(run_case "release" "v2.35" "false" "" "" "") +assert_equals "two-segment v2.35 fails regex and is skipped" \ + "$actual" \ + '::notice::Skipping v2.35: not a plain vX.Y.Z release tag.' + +actual=$(run_case "release" "release-2.35" "false" "" "" "") +assert_equals "release-2.35 fails regex and is skipped" \ + "$actual" \ + '::notice::Skipping release-2.35: not a plain vX.Y.Z release tag.' + +# v0.0.0 satisfies the regex by design. Defense in depth lives in the +# downstream allowlist gate and the workflow's main|release/* case +# validator; this test pins the regex behavior so a future tightening +# is intentional. +actual=$(run_case "release" "v0.0.0" "false" "" "" "") +assert_equals "v0.0.0 satisfies the regex; allowlist is the gate" \ + "$actual" \ + $'::notice::Release v0.0.0 resolved to ref release/0.0.\nACTION=index\nREF=release/0.0' + +# Empty tag with prerelease unset reaches the non-semver skip and +# prints for the tag. The :- defaults in the workflow +# determine the substitution; this test pins both. +actual=$(EVENT_NAME=release \ + GITHUB_REF_NAME='' \ + INPUT_ACTION='' \ + INPUT_REF='' \ + RELEASE_TAG='' \ + RELEASE_PRERELEASE='' \ + compute_action_ref) +assert_equals "empty tag with prerelease unset prints and skips" \ + "$actual" \ + '::notice::Skipping : not a plain vX.Y.Z release tag.' + +# --------------------------------------------------------------- +start_section "regex boundary cases" +# --------------------------------------------------------------- + +# Multi-digit minor and patch components should resolve, since +# backports may carry doc updates worth reindexing. +actual=$(run_case "release" "v2.100.42" "false" "" "" "") +assert_equals "multi-digit minor and patch resolve correctly" \ + "$actual" \ + $'::notice::Release v2.100.42 resolved to ref release/2.100.\nACTION=index\nREF=release/2.100' + +# Trailing build metadata is not a plain vX.Y.Z, so it is skipped. +actual=$(run_case "release" "v2.35.0+build.1" "false" "" "" "") +assert_equals "semver build metadata is skipped" \ + "$actual" \ + '::notice::Skipping v2.35.0+build.1: not a plain vX.Y.Z release tag.' + +# Leading whitespace is not a plain vX.Y.Z; the workflow rejects +# malformed tags instead of trimming them. +actual=$(run_case "release" " v2.35.0" "false" "" "" "") +assert_equals "leading whitespace fails the regex" \ + "$actual" \ + $'::notice::Skipping v2.35.0: not a plain vX.Y.Z release tag.' + +if [ "$failures" -gt 0 ]; then + echo + echo "$failures test(s) failed." + exit 1 +fi + +echo +echo "All tests passed."