Files
coder/.github/workflows/test-deploy-docs-release.sh
T
Nick Vigilante fb24110933 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)

<details>
<summary>11 event scenarios I walked through by hand</summary>

| 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 `<unknown>` +
`exit 0` |

</details>

## 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/<int>.<int>`. 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._
2026-06-12 17:54:32 -04:00

218 lines
7.8 KiB
Bash
Executable File

#!/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:-<unknown>}; 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:-<unknown>}: 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 <unknown> 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 <unknown> and skips" \
"$actual" \
'::notice::Skipping <unknown>: 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."