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."