From 625074831d466e0bc84ea75e06061069516e6fb3 Mon Sep 17 00:00:00 2001 From: Dmytro Sydorov Date: Fri, 16 Oct 2026 17:23:51 +0200 Subject: [PATCH 1/3] feat(vcluster-release)!: check release.yaml before tagging, through release-lib vcluster-release now sources release-lib for its routing helpers instead of carrying copies, and runs platform-release's dispatch preflight before any tag. The preflight moves into release-lib so both dispatchers share it, and it forwards source_branch to lines that declare it. A resumed tag has to be on the branch the cut routes to, and a rejected dispatch says the tag is left for a re-run to resume. The preflight needs mikefarah yq on the runner, so this ships as vcluster-release/v2. --- .github/actions/platform-release/README.md | 8 +- .../platform-release/src/platform-release.sh | 255 --------- .../test/platform-release.bats | 12 + .github/actions/release-lib/lib.sh | 277 +++++++++- .github/actions/vcluster-release/README.md | 50 +- .../vcluster-release/src/vcluster-release.sh | 462 ++++++---------- .../test/vcluster-release.bats | 495 +++++++++++++++++- Makefile | 2 +- README.md | 11 +- 9 files changed, 969 insertions(+), 603 deletions(-) diff --git a/.github/actions/platform-release/README.md b/.github/actions/platform-release/README.md index 42b5609e..0f8d4f47 100644 --- a/.github/actions/platform-release/README.md +++ b/.github/actions/platform-release/README.md @@ -239,9 +239,11 @@ up where the last one stopped: step. A passed build counts only if it built the version as it went out, at any commit and whether or not the tag still exists. One from before the version was pulled on purpose and cut again does not. -- **The tag is not on the target branch** (a stable tag made by hand on - `main`, or a `-next` tag re-run with another `source-branch`): the re-run - refuses rather than build the wrong branch. +- **The tag is not on the target branch** (a stable tag made by hand on a + `main` commit the line does not have, or a `-next` tag re-run from a branch + that does not contain it): the re-run refuses rather than build the wrong + branch. The check is ancestry, so a branch that holds the tagged commit + passes. - **The `-next` feature branch was deleted after tagging:** the re-run warns and resumes from the tag. A missing `main` or `release-X.Y` branch is refused. - **A build at the tag passed, in any attempt, but there is no published diff --git a/.github/actions/platform-release/src/platform-release.sh b/.github/actions/platform-release/src/platform-release.sh index a3de5572..97acf365 100755 --- a/.github/actions/platform-release/src/platform-release.sh +++ b/.github/actions/platform-release/src/platform-release.sh @@ -82,261 +82,6 @@ DISPATCH_VISIBLE_SLEEP_SECONDS="${PLATFORM_DISPATCH_VISIBLE_SLEEP_SECONDS:-5}" # shellcheck source=.github/actions/release-lib/lib.sh disable=SC1091 source "$(dirname "${BASH_SOURCE[0]}")/../../release-lib/lib.sh" -# require_yq - hard error unless mikefarah yq v4.46.1 or later is on PATH. The -# preflight reads the workflow with it, and the python yq wrapper shares the -# name but not the language, so a wrong yq would misread rather than fail. -# GitHub-hosted Ubuntu runners ship a current mikefarah yq. 4.46.1 is the oldest -# release the workflow_facts expression was checked against. -require_yq() { - local out major minor patch - if ! command -v yq >/dev/null 2>&1; then - echo "::error::yq is not on PATH. This action reads release.yaml with mikefarah yq v4.46.1 or later, which GitHub-hosted Ubuntu runners include. Nothing was tagged." >&2 - exit 1 - fi - if ! out="$(yq --version 2>&1)"; then - echo "::error::yq is on PATH at $(command -v yq) but could not run: $(flatten "$out"). This action needs a working mikefarah yq v4.46.1 or later. Nothing was tagged." >&2 - exit 1 - fi - out="$(flatten "$out")" - if [[ ! "$out" =~ mikefarah/yq.*version\ v?([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then - echo "::error::yq on PATH is not mikefarah yq (it reports '${out}'). This action needs mikefarah yq v4.46.1 or later. Nothing was tagged." >&2 - exit 1 - fi - major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" patch="${BASH_REMATCH[3]}" - if ((major != 4 || minor < 46 || (minor == 46 && patch < 1))); then - echo "::error::mikefarah yq ${major}.${minor}.${patch} is on PATH, and this action needs v4.46.1 or later within v4. Nothing was tagged." >&2 - exit 1 - fi -} - -# workflow_facts - read a workflow on stdin and print what the preflight needs, -# one fact per line: -# merge_keys `<<` merge keys anywhere in the file -# duplicate_keys mappings that repeat a key -# trigger one per trigger, in any of the three spellings -# bad_triggers triggers whose names cannot be printed -# bad_inputs workflow_dispatch inputs whose names cannot be printed -# input [type] -# one per workflow_dispatch input, flags 0 or 1; -# a null or empty-string default does not count; -# type is printed only when it is a word -# Merge and duplicate keys are counted before aliases are expanded, since -# expanding hides them. Only string keys shaped like event and input names are -# printed, which keeps a key carrying a newline from forging a line. The rest -# are counted instead, so the preflight can refuse what it cannot see. -# -# Two yq quirks shape the expression: a string literal after a pipe ignores an -# empty input (so every line is built from the data it reports, and the flags -# are array lengths), and a comma branch after `as $var` drops extra results (so -# the lines are collected into one array first). -workflow_facts() { - # shellcheck disable=SC2016 # $on, $inputs and friends are yq variables - yq -r ' - select(document_index == 0) | - ([.. | select(tag == "!!map") | keys | .[] | select(tag == "!!merge")] | length) as $merges | - ([.. | select(tag == "!!map") | select((keys | length) != (keys | unique | length))] | length) as $dups | - explode(.) | .on as $on | - ($on | select(tag == "!!map") | .workflow_dispatch | select(tag == "!!map") | .inputs | select(tag == "!!map")) as $inputs | - [ - ("merge_keys " + ($merges | tostring)), - ("duplicate_keys " + ($dups | tostring)), - ($on | select(tag == "!!str") | select(test("^[a-z_]+$")) | "trigger " + .), - ($on | select(tag == "!!seq") | .[] | select(tag == "!!str") | select(test("^[a-z_]+$")) | "trigger " + .), - ($on | select(tag == "!!map") | keys | .[] | select(tag == "!!str") | select(test("^[a-z_]+$")) | "trigger " + .), - ("bad_triggers " + ([ - ($on | select(tag == "!!str") | select(test("^[a-z_]+$") == false)), - ($on | select(tag == "!!seq") | .[] | select(tag != "!!str" or (tag == "!!str" and test("^[a-z_]+$") == false))), - ($on | select(tag == "!!map") | keys | .[] | select(tag != "!!str" or (tag == "!!str" and test("^[a-z_]+$") == false))) - ] | length | tostring)), - ("bad_inputs " + ([$inputs | keys | .[] | select(tag != "!!str" or (tag == "!!str" and test("^[A-Za-z0-9_-]+$") == false))] | length | tostring)), - ($inputs | to_entries | .[] | select(.key | tag == "!!str") | select(.key | test("^[A-Za-z0-9_-]+$")) | - "input " + .key + " " + - ([.value | select(tag == "!!map") | .required | select((tag == "!!bool" or tag == "!!str") and ((tostring | downcase) == "true"))] | length | tostring) + " " + - ([.value | select(tag == "!!map") | select(has("default")) | .default | - select(tag != "!!null") | select((tag == "!!str" and length == 0) | not)] | length | tostring) + " " + - ([.value | select(tag == "!!map") | select(has("type"))] | length | tostring) + " " + - ([.value | select(tag == "!!map") | select(has("type")) | .type | - select(tag == "!!str") | select(test("^[A-Za-z0-9_-]+$"))] | join(""))) - ] | .[] - ' -} - -# require_dispatchable [label] - refuse to tag when -# the workflow about to be dispatched cannot be dispatched at that ref, or could -# start a build of its own. The ref is a commit sha in a real cut, so `label` -# carries the branch name the operator typed into the messages; it defaults to -# the ref for a direct call. -# -# The tag is created BEFORE the dispatch. A failed dispatch leaves the tag in -# place for the re-run to resume from, but the repo's tag-push workflows have -# already fired for it. Dry-run skips the dispatch, and the workflows API -# answers for the default branch rather than for an arbitrary ref, so reading -# the file at the ref is the only way to catch it before the tag exists. -# -# Read-only, so it runs in dry-run too: a dry-run against an unconverted line -# fails instead of printing a cut that would strand a tag. -require_dispatchable() { - local repo="$1" ref="$2" wf="$3" tag="$4" label="${5:-$2}" body err - # Only the failure branch needs gh's stderr, so it is captured separately - # rather than merged in, where a notice would become part of the YAML read. - if ! run_captured body err gh api "repos/${repo}/contents/.github/workflows/${wf}?ref=${ref}" -H "Accept: application/vnd.github.raw"; then - echo "::error::could not read .github/workflows/${wf} at '${label}' in ${repo}, so the cut cannot confirm the build is dispatchable. Nothing was tagged. gh said: $(gh_reason "$err")" >&2 - exit 1 - fi - # A workflow yq cannot parse cannot be dispatched either. Only yq's `Error:` - # line is quoted: the warnings it prints first would push the reason past - # where GitHub truncates an annotation. - local facts reason_line - if ! run_captured facts err workflow_facts <<<"$body"; then - reason_line="$(grep -m1 '^Error:' <<<"$err" || printf '%s' "$err")" - echo "::error::${wf} at '${label}' in ${repo} is not YAML this preflight can parse, so it cannot confirm the dispatch would succeed before the tag is created. Nothing was tagged. yq said: $(flatten "$reason_line")" >&2 - exit 1 - fi - # Read once. `seen` holds the trigger lines as printed; inputs keep their - # declaration order so the messages below list them the same way. - local -A seen=() required=() has_default=() input_type=() - local -a inputs=() - local kind name req def has_type typ merges=0 dups=0 bad=0 bad_triggers=0 - while read -r kind name req def has_type typ; do - case "$kind" in - merge_keys) merges="$name" ;; - duplicate_keys) dups="$name" ;; - bad_inputs) bad="$name" ;; - bad_triggers) bad_triggers="$name" ;; - input) - inputs+=("$name"); required["$name"]="$req"; has_default["$name"]="$def" - # GitHub reads an input with no type as a string. - input_type["$name"]="string" - [[ "$has_type" == "1" ]] && input_type["$name"]="$typ" ;; - ?*) seen["$kind $name"]=1 ;; - esac - done <<<"$facts" - # GitHub's workflow parser supports anchors and aliases but not `<<` merge - # keys, and rejects a repeated key, so either one would fail the dispatch - # after the tag exists even though yq reads the file happily. - if [[ "$merges" != "0" || "$dups" != "0" ]]; then - echo "::error::${wf} at '${label}' in ${repo} uses a YAML merge key (<<) or repeats a key, which GitHub Actions does not accept, so the dispatch would fail after the tag was created. Write the keys out in full, once each. Nothing was tagged." >&2 - exit 1 - fi - # All three spellings of `on:` list their triggers, and only the immediate - # children of `on:` count: a deeper key named `workflow_dispatch` or `push` - # (an input, a choice option) is not a trigger. - if [[ -z "${seen[trigger workflow_dispatch]:-}" ]]; then - echo "::error::${wf} at '${label}' in ${repo} has no workflow_dispatch trigger, so the dispatch would fail after the tag was created. Convert that line's ${wf} to workflow_dispatch (release-as-output) before cutting it. Nothing was tagged." >&2 - exit 1 - fi - # The tag is an input to ONE build, the dispatched one, and the GitHub Release - # is that build's output. Only workflow_dispatch and workflow_call are allowed, - # since neither fires on its own. Any other trigger is refused whatever its - # filters say: push and create fire for the tag, release fires when the - # dispatched build publishes, and the rest have no business in a release - # workflow. Reading GitHub's filter rules here would be a second copy of them - # to keep in step. release.yaml itself also refuses to build on anything but a - # dispatch, so this check is the early warning, not the only guard. - local other="" entry - for entry in "${!seen[@]}"; do - [[ "$entry" == "trigger "* ]] || continue - case "${entry#trigger }" in - workflow_dispatch | workflow_call) ;; - *) other+="${other:+, }${entry#trigger }" ;; - esac - done - if [[ "$bad_triggers" != "0" ]]; then - other+="${other:+, }${bad_triggers} whose name is not a GitHub event" - fi - if [[ -n "$other" ]]; then - echo "::error::${wf} at '${label}' in ${repo} triggers on more than workflow_dispatch (${other}), so it could start a second build of the tag racing the dispatched one for the release. Leave only workflow_dispatch (and workflow_call, if other workflows call it) - the dispatched build creates the release. Nothing was tagged." >&2 - exit 1 - fi - # An input whose name cannot be quoted back safely cannot be checked either. - # GitHub only allows letters, digits, `-` and `_` there anyway. - if [[ "$bad" != "0" ]]; then - echo "::error::${wf} at '${label}' in ${repo} declares workflow_dispatch input names outside letters, digits, '-' and '_', which this preflight cannot check, so it cannot confirm the dispatch would succeed before the tag is created. Nothing was tagged." >&2 - exit 1 - fi - # Only workflow_dispatch's own inputs count. A release.yaml that is also - # callable declares triggered_by under workflow_call for its callers, and that - # says nothing about what `gh workflow run` accepts. - # - # `gh workflow run` rejects an undeclared input, and the dispatch below passes - # triggered_by whenever it is set, so a line that predates that input fails - # after the tag exists. - # - # Matched exactly. Whether the API would accept `-f triggered_by` for an input - # declared as `Triggered_By` is not something this preflight can confirm, and - # guessing wrong fails the cut after the tag exists, so a differently cased - # declaration is refused by name instead. - if [[ -n "${TRIGGERED_BY}" && -z "${required[triggered_by]+x}" ]]; then - local cased="" - for name in "${inputs[@]}"; do - [[ "${name,,}" == "triggered_by" ]] && cased="$name" - done - if [[ -n "$cased" ]]; then - echo "::error::${wf} at '${label}' in ${repo} declares '${cased}' under workflow_dispatch, but this cut passes 'triggered_by'. Rename the input to 'triggered_by' in that line's ${wf}. Nothing was tagged." >&2 - else - echo "::error::${wf} at '${label}' in ${repo} declares no 'triggered_by' input under workflow_dispatch, and \`gh workflow run\` rejects an undeclared input, so the dispatch would fail after the tag was created. Add the input to that line's ${wf}. Nothing was tagged." >&2 - fi - exit 1 - fi - # Decided here, once, so the type check below covers exactly what the - # dispatch sends. - FORWARD_SOURCE_BRANCH="" - if [[ -n "${required[source_branch]+x}" ]]; then - if [[ -z "${SOURCE_BRANCH_UNKNOWN}" || - ( "${required[source_branch]}" == "1" && "${has_default[source_branch]}" == "0" ) ]]; then - FORWARD_SOURCE_BRANCH=1 - else - echo "::notice::not passing source_branch: ${tag} may have been tagged on ${DEFAULT_BRANCH} before its release line existed, so the release notification detects the branch from git history." - fi - fi - # Every input the cut passes gets a free-form name. Any type but string can - # reject it, which 422s after the tag exists. - local passed=() declared - [[ -n "${TRIGGERED_BY}" ]] && passed+=(triggered_by) - [[ -n "${FORWARD_SOURCE_BRANCH}" ]] && passed+=(source_branch) - for name in ${passed[@]+"${passed[@]}"}; do - [[ "${input_type[$name]}" == "string" ]] && continue - declared="type '${input_type[$name]}'" - [[ -n "${input_type[$name]}" ]] || declared="a type that is not a plain word" - echo "::error::${wf} at '${label}' in ${repo} declares '${name}' under workflow_dispatch with ${declared}, but this cut passes it a name, which only 'type: string' is sure to accept. Make it 'type: string' (lowercase) in that line's ${wf}. Nothing was tagged." >&2 - exit 1 - done - # The mirror image: an input this cut does NOT pass, declared required with no - # default, 422s with "Required input not provided" - again after the tag - # exists. The dispatch sends triggered_by and source_branch and nothing else. - # A required input that has a default is fine; the API fills it in. An - # empty-string default is not counted, since an empty value may itself read - # as not provided. - local unsatisfiable="" - for name in "${inputs[@]}"; do - [[ "${required[$name]}" == "1" && "${has_default[$name]}" == "0" ]] || continue - [[ -n "${TRIGGERED_BY}" && "$name" == "triggered_by" ]] && continue - [[ "$name" == "source_branch" ]] && continue - unsatisfiable+="${unsatisfiable:+, }${name}" - done - if [[ -n "$unsatisfiable" ]]; then - echo "::error::${wf} at '${label}' in ${repo} declares required workflow_dispatch input(s) with no default that this cut does not pass (${unsatisfiable}), so the dispatch would fail after the tag was created. Give them a default in that line's ${wf}, or make them optional. Nothing was tagged." >&2 - exit 1 - fi -} - -# require_workflow_active - refuse to tag when GitHub would -# not run at all. A disabled workflow, or one GitHub has not -# registered, fails the dispatch whatever the file at the ref says, and GitHub -# keeps that state per workflow rather than per ref, which is why this is a -# second read next to require_dispatchable. -require_workflow_active() { - local repo="$1" wf="$2" state - if ! api_get state "repos/${repo}/actions/workflows/${wf}" "workflow ${wf} in ${repo}" '.state'; then - echo "::error::GitHub does not list ${wf} as a workflow in ${repo}, so \`gh workflow run\` cannot dispatch it. Nothing was tagged." >&2 - exit 1 - fi - if [[ "$state" != "active" ]]; then - echo "::error::${wf} in ${repo} is $(flatten "$state"), not active, so the dispatch would fail after the tag was created. Enable it before cutting. Nothing was tagged." >&2 - exit 1 - fi -} - # The README sections a refusal points at for what to do next. The steps depend # on what the runs do next, so they live there rather than in the error. Linked # at the ref the caller runs, so the steps match the code printing the error. diff --git a/.github/actions/platform-release/test/platform-release.bats b/.github/actions/platform-release/test/platform-release.bats index 22be23d6..02d812f8 100644 --- a/.github/actions/platform-release/test/platform-release.bats +++ b/.github/actions/platform-release/test/platform-release.bats @@ -1605,6 +1605,18 @@ EOF TRIGGERED_BY="" INPUT_VERSION="v4.11.0-rc.2" INPUT_DRY_RUN="false" run main [ "$status" -eq 1 ] [[ "$output" == *"declares 'source_branch' under workflow_dispatch with type 'choice'"* ]] + # A resume reads the file at the tag, so fixing the branch cannot clear it. + [[ "$output" == *"The file is read at existing tag v4.11.0-rc.2, which a re-run never moves"* ]] +} + +@test "main: a refusal on a fresh cut does not mention an existing tag" { + export GH_STUB_BRANCHES="loft-sh/loft-enterprise:release-4.11" + export GH_STUB_WF_SOURCE_BRANCH=1 + export GH_STUB_WF_SOURCE_BRANCH_TYPE=choice + TRIGGERED_BY="" INPUT_VERSION="v4.11.3" INPUT_DRY_RUN="false" run main + [ "$status" -eq 1 ] + [[ "$output" == *"declares 'source_branch' under workflow_dispatch with type 'choice'"* ]] + [[ "$output" != *"existing tag"* ]] } @test "main: a resumed rc leaves out a required source_branch that has a default" { diff --git a/.github/actions/release-lib/lib.sh b/.github/actions/release-lib/lib.sh index c7122235..1656a74a 100644 --- a/.github/actions/release-lib/lib.sh +++ b/.github/actions/release-lib/lib.sh @@ -6,6 +6,9 @@ # DEFAULT_BRANCH the branch alpha/beta (and a pre-branch rc) come from # LINE_BRANCH_FORMAT printf format over (major, minor) for a line branch # LINE_BRANCH_PATTERN regex that matches a line branch name +# TRIGGERED_BY the human the dispatch names, or empty to pass none +# SOURCE_BRANCH_UNKNOWN set when the cut cannot name the branch a tag came from +# require_dispatchable sets FORWARD_SOURCE_BRANCH for the caller's dispatch. # # The version grammar matches loft-sh/semstat's `validate` and `type` on purpose, # with one deliberate difference: build metadata is refused here, since nothing @@ -581,27 +584,27 @@ require_tag_on_target() { echo "::warning::branch '${target}' no longer exists in ${repo}, so the cut cannot check that ${tag} (${sha}) came from it. Resuming at the existing tag." return 0 fi - echo "::error::branch '${target}' not found in ${repo}, so the cut cannot check that ${tag} (${sha}) is on it. Nothing was dispatched." >&2 + echo "::error::branch '${target}' not found in ${repo}, so the cut cannot check that ${tag} (${sha}) is on it. Nothing was dispatched in ${repo}." >&2 exit 1 fi if [[ -z "$head" ]]; then - echo "::error::could not resolve HEAD sha for branch '${target}' in ${repo}. Nothing was dispatched." >&2 + echo "::error::could not resolve HEAD sha for branch '${target}' in ${repo}. Nothing was dispatched in ${repo}." >&2 exit 1 fi [[ "$head" == "$sha" ]] && return 0 # compare/...: "behind" means the tag commit is an ancestor of the # branch head. if ! api_get status "repos/${repo}/compare/${head}...${sha}" "comparison of ${tag} with ${target} in ${repo}" '.status // empty'; then - echo "::error::GitHub could not compare ${tag} (${sha}) with ${target} in ${repo}. Nothing was dispatched." >&2 + echo "::error::GitHub could not compare ${tag} (${sha}) with ${target} in ${repo}. Nothing was dispatched in ${repo}." >&2 exit 1 fi case "$status" in identical | behind) return 0 ;; ahead | diverged) - echo "::error::tag ${tag} points at ${sha}, which is not on ${target} (${status}), the branch ${tag} has to be cut from. Resuming would build it from the wrong branch. Nothing was dispatched. If the tag is wrong, delete it by hand and cut again." >&2 + echo "::error::tag ${tag} in ${repo} points at ${sha}, which is not on ${target} (${status}), the branch ${tag} has to be cut from. Resuming would build it from the wrong branch. Nothing was dispatched in ${repo}, and a re-run never moves an existing tag." >&2 exit 1 ;; *) - echo "::error::unexpected comparison status '$(flatten "$status")' for ${tag} against ${target} in ${repo}. Nothing was dispatched." >&2 + echo "::error::unexpected comparison status '$(flatten "$status")' for ${tag} against ${target} in ${repo}. Nothing was dispatched in ${repo}." >&2 exit 1 ;; esac } @@ -723,3 +726,267 @@ require_rc_commit() { echo "::error::${head}: ${what} is ${drift}, the last rc. A stable release ships the exact commit its last rc was cut from. ${after} ${left} ${link}" >&2 exit 1 } + +# --------------------------------------------------------------------------- +# Dispatch preflight +# --------------------------------------------------------------------------- + +# require_yq - hard error unless mikefarah yq v4.46.1 or later is on PATH. The +# preflight reads the workflow with it, and the python yq wrapper shares the +# name but not the language, so a wrong yq would misread rather than fail. +# GitHub-hosted Ubuntu runners ship a current mikefarah yq. 4.46.1 is the oldest +# release the workflow_facts expression was checked against. +require_yq() { + local out major minor patch + if ! command -v yq >/dev/null 2>&1; then + echo "::error::yq is not on PATH. This action reads release.yaml with mikefarah yq v4.46.1 or later, which GitHub-hosted Ubuntu runners include. Nothing was tagged." >&2 + exit 1 + fi + if ! out="$(yq --version 2>&1)"; then + echo "::error::yq is on PATH at $(command -v yq) but could not run: $(flatten "$out"). This action needs a working mikefarah yq v4.46.1 or later. Nothing was tagged." >&2 + exit 1 + fi + out="$(flatten "$out")" + if [[ ! "$out" =~ mikefarah/yq.*version\ v?([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + echo "::error::yq on PATH is not mikefarah yq (it reports '${out}'). This action needs mikefarah yq v4.46.1 or later. Nothing was tagged." >&2 + exit 1 + fi + major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" patch="${BASH_REMATCH[3]}" + if ((major != 4 || minor < 46 || (minor == 46 && patch < 1))); then + echo "::error::mikefarah yq ${major}.${minor}.${patch} is on PATH, and this action needs v4.46.1 or later within v4. Nothing was tagged." >&2 + exit 1 + fi +} + +# workflow_facts - read a workflow on stdin and print what the preflight needs, +# one fact per line: +# merge_keys `<<` merge keys anywhere in the file +# duplicate_keys mappings that repeat a key +# trigger one per trigger, in any of the three spellings +# bad_triggers triggers whose names cannot be printed +# bad_inputs workflow_dispatch inputs whose names cannot be printed +# input [type] +# one per workflow_dispatch input, flags 0 or 1; +# a null or empty-string default does not count; +# type is printed only when it is a word +# Merge and duplicate keys are counted before aliases are expanded, since +# expanding hides them. Only string keys shaped like event and input names are +# printed, which keeps a key carrying a newline from forging a line. The rest +# are counted instead, so the preflight can refuse what it cannot see. +# +# Two yq quirks shape the expression: a string literal after a pipe ignores an +# empty input (so every line is built from the data it reports, and the flags +# are array lengths), and a comma branch after `as $var` drops extra results (so +# the lines are collected into one array first). +workflow_facts() { + # shellcheck disable=SC2016 # $on, $inputs and friends are yq variables + yq -r ' + select(document_index == 0) | + ([.. | select(tag == "!!map") | keys | .[] | select(tag == "!!merge")] | length) as $merges | + ([.. | select(tag == "!!map") | select((keys | length) != (keys | unique | length))] | length) as $dups | + explode(.) | .on as $on | + ($on | select(tag == "!!map") | .workflow_dispatch | select(tag == "!!map") | .inputs | select(tag == "!!map")) as $inputs | + [ + ("merge_keys " + ($merges | tostring)), + ("duplicate_keys " + ($dups | tostring)), + ($on | select(tag == "!!str") | select(test("^[a-z_]+$")) | "trigger " + .), + ($on | select(tag == "!!seq") | .[] | select(tag == "!!str") | select(test("^[a-z_]+$")) | "trigger " + .), + ($on | select(tag == "!!map") | keys | .[] | select(tag == "!!str") | select(test("^[a-z_]+$")) | "trigger " + .), + ("bad_triggers " + ([ + ($on | select(tag == "!!str") | select(test("^[a-z_]+$") == false)), + ($on | select(tag == "!!seq") | .[] | select(tag != "!!str" or (tag == "!!str" and test("^[a-z_]+$") == false))), + ($on | select(tag == "!!map") | keys | .[] | select(tag != "!!str" or (tag == "!!str" and test("^[a-z_]+$") == false))) + ] | length | tostring)), + ("bad_inputs " + ([$inputs | keys | .[] | select(tag != "!!str" or (tag == "!!str" and test("^[A-Za-z0-9_-]+$") == false))] | length | tostring)), + ($inputs | to_entries | .[] | select(.key | tag == "!!str") | select(.key | test("^[A-Za-z0-9_-]+$")) | + "input " + .key + " " + + ([.value | select(tag == "!!map") | .required | select((tag == "!!bool" or tag == "!!str") and ((tostring | downcase) == "true"))] | length | tostring) + " " + + ([.value | select(tag == "!!map") | select(has("default")) | .default | + select(tag != "!!null") | select((tag == "!!str" and length == 0) | not)] | length | tostring) + " " + + ([.value | select(tag == "!!map") | select(has("type"))] | length | tostring) + " " + + ([.value | select(tag == "!!map") | select(has("type")) | .type | + select(tag == "!!str") | select(test("^[A-Za-z0-9_-]+$"))] | join(""))) + ] | .[] + ' +} + +# require_dispatchable [label] [left] - refuse to +# tag when the workflow about to be dispatched cannot be dispatched at that ref, +# or could start a build of its own. The ref is a commit sha in a real cut, so +# `label` carries the branch name the operator typed into the messages; it +# defaults to the ref for a direct call. `left` is what a refusal reports this +# cut has already done, for a check that runs after another repo was tagged. +# +# The tag is created BEFORE the dispatch. A failed dispatch leaves the tag in +# place for the re-run to resume from, but the repo's tag-push workflows have +# already fired for it. Dry-run skips the dispatch, and the workflows API +# answers for the default branch rather than for an arbitrary ref, so reading +# the file at the ref is the only way to catch it before the tag exists. +# +# Read-only, so it runs in dry-run too: a dry-run against an unconverted line +# fails instead of printing a cut that would strand a tag. +require_dispatchable() { + local repo="$1" ref="$2" wf="$3" tag="$4" label="${5:-$2}" left="${6:-Nothing was tagged.}" body err + # Both dispatchers label a resume with the tag itself. There every refusal + # below is about a file the tag pins, which no branch fix can change. + local at_tag="" + [[ "$label" == "$tag" ]] && at_tag=" The file is read at existing tag ${tag}, which a re-run never moves, so a change on the branch does not reach it." + # Only the failure branch needs gh's stderr, so it is captured separately + # rather than merged in, where a notice would become part of the YAML read. + if ! run_captured body err gh api "repos/${repo}/contents/.github/workflows/${wf}?ref=${ref}" -H "Accept: application/vnd.github.raw"; then + echo "::error::could not read .github/workflows/${wf} at '${label}' in ${repo}, so the cut cannot confirm the build is dispatchable. ${left} gh said: $(gh_reason "$err")" >&2 + exit 1 + fi + # A workflow yq cannot parse cannot be dispatched either. Only yq's `Error:` + # line is quoted: the warnings it prints first would push the reason past + # where GitHub truncates an annotation. + local facts reason_line + if ! run_captured facts err workflow_facts <<<"$body"; then + reason_line="$(grep -m1 '^Error:' <<<"$err" || printf '%s' "$err")" + echo "::error::${wf} at '${label}' in ${repo} is not YAML this preflight can parse, so it cannot confirm the dispatch would succeed before the tag is created.${at_tag} ${left} yq said: $(flatten "$reason_line")" >&2 + exit 1 + fi + # Read once. `seen` holds the trigger lines as printed; inputs keep their + # declaration order so the messages below list them the same way. + local -A seen=() required=() has_default=() input_type=() + local -a inputs=() + local kind name req def has_type typ merges=0 dups=0 bad=0 bad_triggers=0 + while read -r kind name req def has_type typ; do + case "$kind" in + merge_keys) merges="$name" ;; + duplicate_keys) dups="$name" ;; + bad_inputs) bad="$name" ;; + bad_triggers) bad_triggers="$name" ;; + input) + inputs+=("$name"); required["$name"]="$req"; has_default["$name"]="$def" + # GitHub reads an input with no type as a string. + input_type["$name"]="string" + [[ "$has_type" == "1" ]] && input_type["$name"]="$typ" ;; + ?*) seen["$kind $name"]=1 ;; + esac + done <<<"$facts" + # GitHub's workflow parser supports anchors and aliases but not `<<` merge + # keys, and rejects a repeated key, so either one would fail the dispatch + # after the tag exists even though yq reads the file happily. + if [[ "$merges" != "0" || "$dups" != "0" ]]; then + echo "::error::${wf} at '${label}' in ${repo} uses a YAML merge key (<<) or repeats a key, which GitHub Actions does not accept, so the dispatch would fail after the tag was created. Write the keys out in full, once each.${at_tag} ${left}" >&2 + exit 1 + fi + # All three spellings of `on:` list their triggers, and only the immediate + # children of `on:` count: a deeper key named `workflow_dispatch` or `push` + # (an input, a choice option) is not a trigger. + if [[ -z "${seen[trigger workflow_dispatch]:-}" ]]; then + echo "::error::${wf} at '${label}' in ${repo} has no workflow_dispatch trigger, so the dispatch would fail after the tag was created. Convert that line's ${wf} to workflow_dispatch (release-as-output) before cutting it.${at_tag} ${left}" >&2 + exit 1 + fi + # The tag is an input to ONE build, the dispatched one, and the GitHub Release + # is that build's output. Only workflow_dispatch and workflow_call are allowed, + # since neither fires on its own. Any other trigger is refused whatever its + # filters say: push and create fire for the tag, release fires when the + # dispatched build publishes, and the rest have no business in a release + # workflow. Reading GitHub's filter rules here would be a second copy of them + # to keep in step. release.yaml itself also refuses to build on anything but a + # dispatch, so this check is the early warning, not the only guard. + local other="" entry + for entry in "${!seen[@]}"; do + [[ "$entry" == "trigger "* ]] || continue + case "${entry#trigger }" in + workflow_dispatch | workflow_call) ;; + *) other+="${other:+, }${entry#trigger }" ;; + esac + done + if [[ "$bad_triggers" != "0" ]]; then + other+="${other:+, }${bad_triggers} whose name is not a GitHub event" + fi + if [[ -n "$other" ]]; then + echo "::error::${wf} at '${label}' in ${repo} triggers on more than workflow_dispatch (${other}), so it could start a second build of the tag racing the dispatched one for the release. Leave only workflow_dispatch (and workflow_call, if other workflows call it) - the dispatched build creates the release.${at_tag} ${left}" >&2 + exit 1 + fi + # An input whose name cannot be quoted back safely cannot be checked either. + # GitHub only allows letters, digits, `-` and `_` there anyway. + if [[ "$bad" != "0" ]]; then + echo "::error::${wf} at '${label}' in ${repo} declares workflow_dispatch input names outside letters, digits, '-' and '_', which this preflight cannot check, so it cannot confirm the dispatch would succeed before the tag is created.${at_tag} ${left}" >&2 + exit 1 + fi + # Only workflow_dispatch's own inputs count. A release.yaml that is also + # callable declares triggered_by under workflow_call for its callers, and that + # says nothing about what `gh workflow run` accepts. + # + # `gh workflow run` rejects an undeclared input, and the dispatch below passes + # triggered_by whenever it is set, so a line that predates that input fails + # after the tag exists. + # + # Matched exactly. Whether the API would accept `-f triggered_by` for an input + # declared as `Triggered_By` is not something this preflight can confirm, and + # guessing wrong fails the cut after the tag exists, so a differently cased + # declaration is refused by name instead. + if [[ -n "${TRIGGERED_BY}" && -z "${required[triggered_by]+x}" ]]; then + local cased="" + for name in "${inputs[@]}"; do + [[ "${name,,}" == "triggered_by" ]] && cased="$name" + done + if [[ -n "$cased" ]]; then + echo "::error::${wf} at '${label}' in ${repo} declares '${cased}' under workflow_dispatch, but this cut passes 'triggered_by'. Rename the input to 'triggered_by' in that line's ${wf}.${at_tag} ${left}" >&2 + else + echo "::error::${wf} at '${label}' in ${repo} declares no 'triggered_by' input under workflow_dispatch, and \`gh workflow run\` rejects an undeclared input, so the dispatch would fail after the tag was created. Add the input to that line's ${wf}.${at_tag} ${left}" >&2 + fi + exit 1 + fi + # Decided here, once, so the type check below covers exactly what the + # dispatch sends. + FORWARD_SOURCE_BRANCH="" + if [[ -n "${required[source_branch]+x}" ]]; then + if [[ -z "${SOURCE_BRANCH_UNKNOWN}" || + ( "${required[source_branch]}" == "1" && "${has_default[source_branch]}" == "0" ) ]]; then + FORWARD_SOURCE_BRANCH=1 + else + echo "::notice::not passing source_branch: ${tag} may have been tagged on ${DEFAULT_BRANCH} before its release line existed, so the release notification detects the branch from git history." + fi + fi + # Every input the cut passes gets a free-form name. Any type but string can + # reject it, which 422s after the tag exists. + local passed=() declared + [[ -n "${TRIGGERED_BY}" ]] && passed+=(triggered_by) + [[ -n "${FORWARD_SOURCE_BRANCH}" ]] && passed+=(source_branch) + for name in ${passed[@]+"${passed[@]}"}; do + [[ "${input_type[$name]}" == "string" ]] && continue + declared="type '${input_type[$name]}'" + [[ -n "${input_type[$name]}" ]] || declared="a type that is not a plain word" + echo "::error::${wf} at '${label}' in ${repo} declares '${name}' under workflow_dispatch with ${declared}, but this cut passes it a name, which only 'type: string' is sure to accept. Make it 'type: string' (lowercase) in that line's ${wf}.${at_tag} ${left}" >&2 + exit 1 + done + # The mirror image: an input this cut does NOT pass, declared required with no + # default, 422s with "Required input not provided" - again after the tag + # exists. The dispatch sends triggered_by and source_branch and nothing else. + # A required input that has a default is fine; the API fills it in. An + # empty-string default is not counted, since an empty value may itself read + # as not provided. + local unsatisfiable="" + for name in "${inputs[@]}"; do + [[ "${required[$name]}" == "1" && "${has_default[$name]}" == "0" ]] || continue + [[ -n "${TRIGGERED_BY}" && "$name" == "triggered_by" ]] && continue + [[ "$name" == "source_branch" ]] && continue + unsatisfiable+="${unsatisfiable:+, }${name}" + done + if [[ -n "$unsatisfiable" ]]; then + echo "::error::${wf} at '${label}' in ${repo} declares required workflow_dispatch input(s) with no default that this cut does not pass (${unsatisfiable}), so the dispatch would fail after the tag was created. Give them a default in that line's ${wf}, or make them optional.${at_tag} ${left}" >&2 + exit 1 + fi +} + +# require_workflow_active - refuse to tag when GitHub would +# not run at all. A disabled workflow, or one GitHub has not +# registered, fails the dispatch whatever the file at the ref says, and GitHub +# keeps that state per workflow rather than per ref, which is why this is a +# second read next to require_dispatchable. +require_workflow_active() { + local repo="$1" wf="$2" state + if ! api_get state "repos/${repo}/actions/workflows/${wf}" "workflow ${wf} in ${repo}" '.state'; then + echo "::error::GitHub does not list ${wf} as a workflow in ${repo}, so \`gh workflow run\` cannot dispatch it. Nothing was tagged." >&2 + exit 1 + fi + if [[ "$state" != "active" ]]; then + echo "::error::${wf} in ${repo} is $(flatten "$state"), not active, so the dispatch would fail after the tag was created. Enable it before cutting. Nothing was tagged." >&2 + exit 1 + fi +} diff --git a/.github/actions/vcluster-release/README.md b/.github/actions/vcluster-release/README.md index a70078de..9756fc50 100644 --- a/.github/actions/vcluster-release/README.md +++ b/.github/actions/vcluster-release/README.md @@ -94,6 +94,37 @@ builds against the OSS code being co-released. Prereleases are not checked. - **Unprepared line:** fails loudly if a required `vX.Y` branch is absent (no silent fallback), distinguishing a real 404 from a transient API error. +- **Undispatchable line:** before anything is tagged, each repo the cut will + dispatch has its `.github/workflows/release.yaml` read at the commit the + dispatch would run: the branch head, which is then the commit tagged, or the + existing tag on a resume. The cut stops if the file has no `workflow_dispatch` + trigger, has a trigger that could start a second build (`push`, `create`, + `release`, ...), lacks an input the dispatch passes, or requires one it does + not, and if GitHub does not list the workflow as `active`. These are the + `require_dispatchable` rules shared with platform-release, listed in + [its README](../platform-release/README.md#guards). On a legacy line both + repos are checked before OSS is tagged, and pro is checked again at the head + the dependency bump leaves, since anything else that lands during the bump + wait is tagged with it. A refusal there leaves the OSS tag and the merged bump + behind, and says so. Monorepo and `-next` dispatches pass `triggered_by`; + legacy ones pass none. On a resume the file is read at the existing tag, so + a refusal there says a fix on the branch cannot reach it. +- **Tag on its branch:** a resumed tag whose commit is not on the branch the + routing chose is not dispatched, in either repo: it would build code that + branch never had, and name that branch in the banner. The check is + ancestry, so it cannot tell which of two branches holding the same commit a + tag was cut from. +- **Source branch:** a line whose `release.yaml` declares a `source_branch` + input gets the branch the cut tagged, for `notify-release`'s `base_branch`. + A line without it is dispatched without one. A monorepo rc resumed on its + `vX.Y` branch is dispatched without it unless `release.yaml` requires it with + no default: the tag may have been cut from `main` before the line was + branched, which the cut cannot tell, so the notifier reads the branch from + git history instead. +- **Branch shape:** `source-branch` is trimmed and validated as a branch name + before anything reads it, since every consumer puts it in a `gh api` path + and gh reads `?` as the start of a query string (`main?` would resolve to + `main` while comparing as a feature branch). - **Dry-run** still performs the read-only checks, so a bad routing decision (missing branch, already-released) is caught before anything is dispatched. Dry-run is **fail-closed**: only an explicit `false` cuts for real; any other @@ -193,9 +224,10 @@ Two refusals from the rc check are not cleared by pressing the button again: loft-sh/vcluster has not moved past its stable tag; otherwise cut the next patch version. -Every other partial failure recovers the same way: fix whatever blocked it (a -bump PR that would not merge, a missing branch, an expired token), then press -**Cut release** again with the same version. Use `dry-run: true` first to print +A dispatch GitHub rejects leaves its tag in the `tagged` state and says so, with +gh's reason. Every other partial failure recovers the same way: fix whatever +blocked it (a bump PR that would not merge, a missing branch, an expired token, +a rejected dispatch), then press **Cut release** again with the same version. Use `dry-run: true` first to print the resume plan without mutating anything. Genuinely re-cutting an already-shipped version is still refused. To force one, @@ -232,13 +264,19 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v1 + - uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v2 with: version: ${{ inputs.version }} dry-run: ${{ inputs.dry_run }} github-token: ${{ secrets.GH_ACCESS_TOKEN }} ``` +### Runner + +The runner needs mikefarah `yq` v4.46.1 or later on `PATH`, which GitHub-hosted +Ubuntu runners include. The action checks this before any API call. `v1` +did not read `release.yaml` and needed no `yq`, which is why this is `v2`. + ### Auth `github-token` must be a Personal Access Token or GitHub App token with `repo` + @@ -260,5 +298,7 @@ make test-vcluster-release ``` Runs the bats suite in `test/` against `src/vcluster-release.sh` with a configurable -`gh` stub on `PATH` (no real API calls). The stub mirrors real `gh` behaviour, +`gh` stub on `PATH` (no real API calls). The preflight parses workflows with the +real `yq`, so mikefarah `yq` v4.46.1+ has to be on `PATH`; the suite stops up +front if it is not. The stub mirrors real `gh` behaviour, including exiting non-zero on a 404. diff --git a/.github/actions/vcluster-release/src/vcluster-release.sh b/.github/actions/vcluster-release/src/vcluster-release.sh index 7f6c0d3b..e510154b 100755 --- a/.github/actions/vcluster-release/src/vcluster-release.sh +++ b/.github/actions/vcluster-release/src/vcluster-release.sh @@ -29,9 +29,13 @@ # dispatch loft-sh/vcluster-pro only. # v0.36 is a legacy line (two-repo dance); v0.37 is the first merged/monorepo line. # +# Before anything is tagged, each repo's release.yaml is read at the commit the +# dispatch would run and refused if that dispatch could not succeed (see +# dispatch_preflight). +# # dry_run (default true) performs the read-only checks (branch existence, -# double-cut guard) so the printed routing decision is validated, but prints the -# mutating tag/dispatch calls instead of firing them. +# double-cut guard, the release.yaml preflight) so the printed routing decision +# is validated, but prints the mutating tag/dispatch calls instead of firing them. set -euo pipefail CUTOVER="${CUTOVER:-v0.37}" @@ -46,6 +50,11 @@ WORKFLOW="${WORKFLOW:-release.yaml}" # suite drives the poll without real sleeps. BUMP_WORKFLOW="${BUMP_WORKFLOW:-release-bump-vcluster.yaml}" PRO_DEFAULT_BRANCH="${PRO_DEFAULT_BRANCH:-main}" +# release-lib's routing reads these. vcluster release branches are named `v0.37`, +# not Platform's `release-4.11`. +DEFAULT_BRANCH="${PRO_DEFAULT_BRANCH}" +LINE_BRANCH_FORMAT="v%s.%s" +LINE_BRANCH_PATTERN='^v[0-9]+\.[0-9]+$' PRO_OWNER="${PRO_REPO%%/*}" # ~120 min ceiling (480 x 15s). Must exceed the bot's own CI wait: the bump PR # triggers e2e and auto-approve-bot-prs only merges once e2e is green, so this @@ -90,12 +99,10 @@ TAG_REF_JQ='"\(.object.sha // "") \(.object.type // "")"' # and gh workflow run rejects undeclared inputs, so legacy dispatches omit it. TRIGGERED_BY="${TRIGGERED_BY:-}" -# For require_rc_commit, shared with platform-release so both dispatchers hold -# a stable to its last rc the same way. Sourced before this script's own -# helpers, so the ones defined below replace the lib's same-named copies and -# this script routes by its own rules. A caller pinning vcluster-release/v1 checks -# out the whole repository at that tag, so the lib read is the one this version -# was written against. +# Routing, probes and the dispatch preflight, shared with platform-release so +# both dispatchers route and refuse the same way. A caller pinning +# vcluster-release/v2 checks out the whole repository at that tag, so the lib +# read is the one this version was written against. # shellcheck source=.github/actions/release-lib/lib.sh disable=SC1091 source "$(dirname "${BASH_SOURCE[0]}")/../../release-lib/lib.sh" @@ -103,73 +110,6 @@ source "$(dirname "${BASH_SOURCE[0]}")/../../release-lib/lib.sh" # Pure helpers (no network) - the routing brain, exhaustively unit-tested. # --------------------------------------------------------------------------- -# normalize_version -> canonical vX.Y.Z[-suffix] -# Operators paste versions from Linear, Slack and release notes, where the -# leading v is inconsistent and a stray space survives a copy. The routing -# helpers below already tolerate both spellings (parse_major_minor strips an -# optional v), but the raw string is used VERBATIM as the tag name and as the -# double-cut probe key - so an un-normalized "0.37.1" would create a v-less tag -# AND sail past the double-cut guard, which probes for "v0.37.1" and gets a 404. -# That silently re-releases an already-shipped version, and the resulting tag can -# never be promoted (promote-release requires ^v[0-9]+\.[0-9]+\.[0-9]+$) nor be -# resolved by the Go module proxy, which requires the v. Normalizing here, before -# anything reads the value, makes both spellings land on the same canonical tag. -normalize_version() { - local v="$1" - v="${v#"${v%%[![:space:]]*}"}" # strip leading whitespace - v="${v%"${v##*[![:space:]]}"}" # strip trailing whitespace - [[ "$v" == V* ]] && v="v${v#V}" # accept a capitalized V - [[ "$v" == v* ]] || v="v${v}" # supply the leading v when missing - printf '%s\n' "$v" -} - -# validate_version - hard-fail anything that is not our tag shape. -# Runs AFTER normalize_version, so a recoverable paste (missing v, capitalized V, -# stray whitespace) has already been repaired and only genuinely malformed input -# reaches here. Deliberately STRICTER than semver, because the value is used -# verbatim as a git tag and every downstream consumer assumes vMAJOR.MINOR.PATCH: -# vX.Y would create a tag colliding with the vX.Y release BRANCH, leaving an -# ambiguous ref that git resolves with a warning. -# vX.Y.Z.N is not semver at all. -# 0.37.1 bare semver is valid to node-semver but cannot be promoted -# (promote-release matches ^v[0-9]+\.[0-9]+\.[0-9]+$) nor resolved by -# the Go module proxy; normalize_version has already fixed it by here. -# Build metadata (+meta) is rejected too: no consumer in the pipeline handles it. -# The prerelease body is only shape-checked here - classify_suffix decides which -# suffixes are actually routable, and is fail-closed on unknown ones. -validate_version() { - local v="$1" - if [[ ! "$v" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::version '${v}' is not a valid release version. Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix, e.g. v0.37.1, v0.37.0-rc.1, v0.37.0-next.internal.3." >&2 - return 1 - fi -} - -# parse_major_minor -> "MAJOR MINOR" -# Accepts v-prefixed or bare, with or without patch/prerelease: -# v0.35.4-rc.1 -> "0 35", v1.0 -> "1 0". Fails loudly on garbage. -parse_major_minor() { - local v="${1#v}" major minor rest - major="${v%%.*}" - rest="${v#*.}" - minor="${rest%%.*}" - # Trim any non-numeric suffix on the minor (e.g. "36-rc.1" -> "36"). - minor="${minor%%[!0-9]*}" - if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then - echo "::error::cannot parse major.minor from version '$1'" >&2 - return 1 - fi - printf '%s %s\n' "$major" "$minor" -} - -# derive_line -> vX.Y -derive_line() { - local mm major minor - mm="$(parse_major_minor "$1")" || return 1 - read -r major minor <<<"$mm" - printf 'v%s.%s\n' "$major" "$minor" -} - # classify_era [cutover] -> "legacy" | "monorepo" # (major, minor) >= (cutover major, minor) is monorepo; anything below is legacy. classify_era() { @@ -185,155 +125,12 @@ classify_era() { fi } -# classify_suffix -> alpha | beta | rc | next | next-internal | stable -# Fail-closed: only the prerelease suffixes the dispatcher knows how to route are -# accepted. Anything else - including a legal-but-unrouted tag such as -# -devpod.alpha - is rejected so an unhandled release type can never be silently -# misrouted onto the wrong branch. Order matters: -next.internal is a sub-flavor -# of -next and must be matched first. -classify_suffix() { - local v="$1" - case "$v" in - *-next.internal.*) printf 'next-internal\n' ;; - *-next.*) printf 'next\n' ;; - *-alpha.*) printf 'alpha\n' ;; - *-beta.*) printf 'beta\n' ;; - *-rc.*) printf 'rc\n' ;; - *-*) - echo "::error::version '$v' has an unsupported prerelease suffix; the dispatcher cuts only -alpha/-beta/-rc/-next/-next.internal or a stable vX.Y.Z" >&2 - return 1 ;; - *) printf 'stable\n' ;; - esac -} - -# is_feature_branch -> 0 for a short-lived feature branch, 1 otherwise. -# A feature branch is anything that is neither main nor a vX.Y release branch. -is_feature_branch() { - local b="$1" - [[ "$b" == "main" ]] && return 1 - [[ "$b" =~ ^v[0-9]+\.[0-9]+$ ]] && return 1 - return 0 -} - -# resolve_target -> the branch to tag, or a hard -# error if violates the matrix. Handles the non-feature suffixes -# only (alpha/beta/rc/stable); next/next.internal are routed by cut_feature_prerelease. -# alpha|beta -> main only -# rc -> main or the line branch vX.Y (main() has already narrowed this -# through resolve_rc_source, which refuses main once vX.Y exists) -# stable -> the line branch vX.Y only -resolve_target() { - local suffix="$1" src="$2" line="$3" - case "$suffix" in - alpha|beta) - if [[ -n "$src" && "$src" != "main" ]]; then - echo "::error::${suffix} releases are cut from main only, not '${src}'" >&2 - return 1 - fi - printf 'main\n' ;; - rc) - # An empty src reaches here only if main() skipped the probe (it does not - # on any live path); main arrives only once resolve_rc_source has confirmed - # the line has not branched yet. Kept so this helper stays total and - # network-free. - if [[ -z "$src" || "$src" == "main" ]]; then - printf 'main\n' - elif [[ "$src" == "$line" ]]; then - printf '%s\n' "$line" - else - echo "::error::rc releases are cut from main or the ${line} release branch, not '${src}'" >&2 - return 1 - fi ;; - stable) - if [[ -n "$src" && "$src" != "$line" ]]; then - echo "::error::stable releases are cut from the ${line} release branch only, not '${src}'" >&2 - return 1 - fi - printf '%s\n' "$line" ;; - *) - echo "::error::resolve_target: unexpected suffix '${suffix}'" >&2 - return 1 ;; - esac -} # --------------------------------------------------------------------------- # Network helpers. Read-only ones always run (they validate the routing, even # in dry-run). Mutating ones honour DRY_RUN. # --------------------------------------------------------------------------- -# gh_reason - gh's own words from a `--silent -i` capture, on one line. -# -# The capture is a ~25-line response-header block with gh's error appended, so -# interpolating it whole pushes the sentence that says WHY past the point where -# GitHub truncates an annotation - measured at byte 1183 of 1273 on a 403. The -# status line and `Name: value` headers carry nothing the message does not -# already state, so they go. Flattening \n\r\t keeps a multi-line body from -# breaking the annotation or forging a second workflow command. -gh_reason() { - local raw="$1" reason - [[ -n "$raw" ]] || { printf ''; return 0; } - # `gh: ...` is kept explicitly, because it matches the `Name: value` shape a - # response header has and a bare header filter eats the one line worth - # printing. Verified against the live API: a 404 comes back as the status - # line, 24 headers, a blank line, then `gh: Not Found (HTTP 404)`. - reason="$(printf '%s\n' "$raw" | awk ' - /^gh:/ { print; next } - /^HTTP\// { next } - /^[A-Za-z][A-Za-z0-9-]*: / { next } - { print } - ' | LC_ALL=C tr '\n\r\t' ' ' | tr -s ' ' | sed 's/^ *//; s/ *$//' || true)" - # Headers but no reason is itself worth saying: it means gh returned a status - # and died without explaining, which reads very differently from no output. - printf '%s' "${reason:-}" -} - -# api_exists -> 0 if 200, 1 if 404, exits 1 on transient/unexpected. -# Shared read-only existence probe. Read only the HTTP status line. On a 404 `gh` -# exits non-zero, so we must capture its output with `|| true` BEFORE parsing - -# piping gh directly into the status-substitution would let pipefail propagate the -# non-zero exit and clobber the code to empty, misreading a real 404 as a transient -# failure. gh writes the "HTTP/2.0 404" status line to stdout even with --silent -# (only the body is suppressed); a genuine transient error (DNS/auth/rate-limit) -# yields no status line, so an empty code correctly means "could not reach the API". -# Distinguishing the two matters to every caller: an unreachable API must never be -# silently read as "absent" (a missed double-cut guard) or "missing" (a wrong branch). -api_exists() { - local path="$1" what="$2" out http_code - # stderr is merged, not discarded. On the no-status path gh's own message is - # the only evidence of what went wrong, and dropping it leaves a diagnosis - # that names three causes and distinguishes none of them. Merging is safe - # because the status line is matched by prefix rather than by position. - out="$(gh api "$path" --silent -i 2>&1 || true)" - # `|| true` is load-bearing under `set -o pipefail`: on the no-status path grep - # matches nothing and exits 1, which would abort here and skip the very - # diagnostic this function exists to print. - http_code="$(printf '%s\n' "$out" | grep -m1 '^HTTP/' | awk '{print $2}' || true)" - case "$http_code" in - 200) return 0 ;; - 404) return 1 ;; - "") - # gh_reason strips the header block and flattens what is left, so the - # cause leads the annotation instead of trailing 1.2KB of headers. - echo "::error::failed to reach GitHub API for ${what} (no HTTP status - DNS, rate-limit, or auth). Not treating as absent. gh said: $(gh_reason "$out")" >&2 - exit 1 - ;; - *) - # Same treatment as the no-status arm: a 403 is where secondary rate - # limits and SSO/scope rejections land, and the status code alone does not - # distinguish them. gh's body is already in ${out}; printing the code and - # discarding the reason would leave the operator reproducing it by hand. - echo "::error::unexpected status ${http_code} from GitHub API for ${what}. gh said: $(gh_reason "$out")" >&2 - exit 1 - ;; - esac -} - -# branch_exists -> 0 if 200, 1 if 404, exits 1 on transient error. -branch_exists() { - local repo="$1" branch="$2" - api_exists "repos/${repo}/branches/${branch}" "branch '${branch}' in ${repo}" -} - # require_branch - hard error if the branch is absent. require_branch() { local repo="$1" branch="$2" @@ -343,52 +140,6 @@ require_branch() { fi } -# resolve_rc_source -> the branch an rc is cut from, or a -# hard error when contradicts the line's actual state. -# -# rc is the one suffix legal from either branch, and which of the two is right is -# not a preference - it is decided by whether the line has branched yet: -# -# vX.Y exists the rc MUST come from vX.Y. main now carries the NEXT line's -# development and is missing the backports that only land on -# vX.Y, so an rc cut from it validates the wrong code. An -# empty source-branch takes vX.Y; an explicit main is refused -# rather than honoured, because at this point it is a mistake -# and nothing downstream would catch it. -# vX.Y absent the rc IS main; that is where the line lives until it -# branches. -# -# So the probe is not just a default-filler: it runs on every monorepo rc, -# including one that named a source branch, because the refusal above depends on -# it. branch_exists is fail-closed (a transient API error aborts rather than -# reading as absent), so an unreachable API can neither silently pick main nor -# silently skip the refusal. -# -# A source branch that is neither main nor vX.Y is passed through untouched for -# resolve_target to reject, keeping that error in one place. -resolve_rc_source() { - local repo="$1" line="$2" src="$3" - if branch_exists "$repo" "$line"; then - if [[ -z "$src" ]]; then - echo "::notice::no source-branch given for an rc and ${line} exists in ${repo}; cutting from ${line}." >&2 - printf '%s\n' "$line" - return 0 - fi - if [[ "$src" == "main" ]]; then - echo "::error::${line} exists in ${repo}, so an rc on this line is cut from ${line}, not from main. main carries the next line's development and lacks the fixes backported to ${line}, so this rc would validate the wrong code. Pass source-branch: ${line}, or leave source-branch empty to take it automatically." >&2 - return 1 - fi - printf '%s\n' "$src" - return 0 - fi - if [[ -z "$src" ]]; then - echo "::notice::no source-branch given for an rc and ${line} does not exist yet in ${repo}; cutting from main." >&2 - printf 'main\n' - return 0 - fi - printf '%s\n' "$src" -} - # runs_at_ref [head-sha] [quiet] - prints " ". # # total runs at , restricted to when one is given @@ -656,8 +407,10 @@ head_of() { printf '%s' "$sha" } -# create_tag [rc-allowed] [rc-mode] - tag the branch head. Inert -# w.r.t. release workflows (they trigger on workflow_dispatch, not tag push). +# create_tag [rc-allowed] [rc-mode] [sha] - tag the branch +# head, or [sha] when the preflight already read it, so the commit whose +# release.yaml was checked is the commit tagged. Inert w.r.t. release workflows +# (they trigger on workflow_dispatch, not tag push). # A stable tag must land on its last rc's commit, plus at most [rc-allowed] # commits on top (see require_rc_commit). [rc-allowed] "bump" reads that number # off go.mod at the commit itself (see pro_allowance). [rc-mode] is passed on to @@ -666,12 +419,12 @@ head_of() { # A stable's head is read in dry-run too, so a preview hits the rc refusal a # real cut would at that head. The preview still prints a placeholder rather # than that sha: on the legacy path the pro head moves with the dependency bump -# before it is tagged. A dry-run prerelease has nothing to check, so it reads -# nothing. +# before it is tagged. A dry-run prerelease reads nothing here, since the +# preflight has already read the head it would tag. create_tag() { - local repo="$1" branch="$2" tag="$3" rc_allowed="${4:-0}" rc_mode="${5:-}" sha="" + local repo="$1" branch="$2" tag="$3" rc_allowed="${4:-0}" rc_mode="${5:-}" sha="${6:-}" if [[ "${DRY_RUN:-true}" != "true" ]] || is_stable_version "$tag"; then - sha="$(head_of "$repo" "$branch")" || exit 1 + [[ -n "$sha" ]] || sha="$(head_of "$repo" "$branch")" || exit 1 if [[ "$rc_allowed" == "bump" ]] && is_stable_version "$tag"; then rc_allowed="$(pro_allowance "$sha" "$tag")" || exit 1 fi @@ -713,7 +466,17 @@ dispatch() { echo "[dry-run] gh workflow run ${WORKFLOW} --repo ${repo} --ref ${tag} ${extra[*]}" return 0 fi - gh workflow run "${WORKFLOW}" --repo "${repo}" --ref "${tag}" "${extra[@]}" + # Checked rather than bare, so the failure says what is left: the tag exists + # with no build, which is the `tagged` state a re-run resumes from. + local out err + if ! run_captured out err gh workflow run "${WORKFLOW}" --repo "${repo}" --ref "${tag}" "${extra[@]}"; then + echo "::error::could not dispatch ${WORKFLOW} in ${repo} at ${tag}, so ${tag} is tagged there with no build. Re-run the cut with the same version: it resumes at this dispatch without re-tagging, and does not dispatch again a build that already started. gh said: $(gh_reason "$err")" >&2 + exit 1 + fi + # Captured only for the failure message. What gh says on success (the run's + # URL, in current gh) still belongs in the log. + [[ -z "$out" ]] || printf '%s\n' "$out" + [[ -z "$err" ]] || printf '%s\n' "$err" >&2 echo "dispatched ${WORKFLOW} in ${repo} at ${tag}" # Block until the run is visible to the same query release_state uses. Until it # registers, a concurrent or immediately-following cut reads this tag as @@ -742,9 +505,10 @@ dispatch() { echo "::warning::${WORKFLOW} was dispatched in ${repo} at ${tag} but the run did not become queryable within $(( DISPATCH_VISIBLE_ATTEMPTS * DISPATCH_VISIBLE_SLEEP_SECONDS ))s. The build is running; just be aware that a cut re-run started right now could dispatch it a second time." } -# ensure_tag [rc-allowed] [rc-mode] - create the tag only -# when this repo has not got one yet, at the branch head and held to its last rc -# (see create_tag). Skipping on a resume is not just an optimisation: the tag a +# ensure_tag [rc-allowed] [rc-mode] [sha] - create +# the tag only when this repo has not got one yet, at the branch head (or [sha]) +# and held to its last rc (see create_tag). On a resume [sha] is the commit the +# tag was already read at. Skipping on a resume is not just an optimisation: the tag a # previous run created is what its already-dispatched build is building, so # re-pointing it would change what ships under a version that is already moving. # @@ -755,10 +519,11 @@ dispatch() { ensure_tag() { local repo="$1" branch="$2" tag="$3" state="$4" rc_allowed="${5:-0}" rc_mode="${6:-}" sha case "$state" in - absent) create_tag "$repo" "$branch" "$tag" "$rc_allowed" "$rc_mode" ;; + absent) create_tag "$repo" "$branch" "$tag" "$rc_allowed" "$rc_mode" "${7:-}" ;; *) if [[ "$state" == "tagged" ]] && is_stable_version "$tag"; then - sha="$(existing_tag_sha "$repo" "$tag")" || exit 1 + sha="${7:-}" + [[ -n "$sha" ]] || sha="$(existing_tag_sha "$repo" "$tag")" || exit 1 if [[ "$rc_allowed" == "bump" ]]; then rc_allowed="$(pro_allowance "$sha" "$tag")" || exit 1 fi @@ -792,6 +557,54 @@ ensure_dispatch() { esac } +# dispatch_preflight - check the release.yaml a +# dispatch at would run (see require_dispatchable) before this cut tags +# anything, since every tag starts the repo's tag-push workflows and a cut that +# cannot build should stop before it makes one. +# +# Sets PREFLIGHT_SHA to the commit it read: the branch head when the tag is +# still to be made, which create_tag then tags, or the tag's commit on a resume, +# which must be on , the branch the routing chose and the one passed on +# as source_branch. Also sets FORWARD_SOURCE_BRANCH for this repo's dispatch. A +# repo whose build is already dispatched or released runs nothing new, so +# nothing is read. +dispatch_preflight() { + local repo="$1" branch="$2" tag="$3" state="$4" label rc=0 + PREFLIGHT_SHA="" FORWARD_SOURCE_BRANCH="" SOURCE_BRANCH_UNKNOWN="" + local cannot="The cut cannot read the ${WORKFLOW} a dispatch would run, and nothing was dispatched in ${repo}." + case "$state" in + absent) + # Read in dry-run too, so the preview checks the commit it would tag. + PREFLIGHT_SHA="$(head_of "$repo" "$branch")" || exit 1 + label="$branch" ;; + tagged) + resolve_tag PREFLIGHT_SHA "$repo" "$tag" || rc=$? + case "$rc" in + 0) ;; + 1) + echo "::error::tag ${tag} was in ${repo} a moment ago and is gone now. ${cannot} Re-run the cut, which reads the state again." >&2 + exit 1 ;; + 2) + echo "::error::${TAG_ERR}. ${cannot} Re-run the cut once GitHub answers." >&2 + exit 1 ;; + *) + echo "::error::${TAG_ERR}. ${cannot}" >&2 + exit 1 ;; + esac + require_tag_on_target "$repo" "$branch" "$tag" "$PREFLIGHT_SHA" + label="$tag" + # A monorepo rc resumed on its line may have been tagged on main before + # the line was branched from a newer main, and the cut cannot tell. + if [[ "$branch" != "$DEFAULT_BRANCH" && "$(classify_suffix "$tag")" == rc && + "$(classify_era "$tag")" == monorepo ]]; then + SOURCE_BRANCH_UNKNOWN=1 + fi ;; + *) return 0 ;; + esac + require_dispatchable "$repo" "$PREFLIGHT_SHA" "$WORKFLOW" "$tag" "$label" + require_workflow_active "$repo" "$WORKFLOW" +} + # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- @@ -1077,7 +890,7 @@ pro_allowance() { } -# require_legacy_rcs - the rc checks +# require_legacy_rcs [pro-sha] - the rc checks # a legacy stable needs before anything is touched. OSS is tagged first and the # bump lands on pro before pro is tagged, so a pro refusal after either would # leave them behind. @@ -1122,13 +935,18 @@ require_legacy_rcs() { require_rc_commit "$OSS_REPO" "$version" "$sha" 0 dependency fi [[ "$pro_state" == "absent" ]] || return 0 - sha="$(head_of "$PRO_REPO" "$line")" || exit 1 + # [pro-sha] is the head the preflight already read. + sha="${5:-}" + [[ -n "$sha" ]] || sha="$(head_of "$PRO_REPO" "$line")" || exit 1 allowed="$(pro_allowance "$sha" "$version")" || exit 1 require_rc_commit "$PRO_REPO" "$version" "$sha" "$allowed" "$mode" } cut_legacy() { local version="$1" line="$2" oss_state pro_state advisory="" + # Legacy release.yaml's do not declare triggered_by, so their dispatches pass + # none and the preflight must not require it. Local, so only this cut sees it. + local TRIGGERED_BY="" echo "Routing ${version} -> legacy (line ${line}); dispatch order: ${OSS_REPO} then ${PRO_REPO}" # Both repos must be ready before we mutate anything. require_branch "$OSS_REPO" "$line" @@ -1143,10 +961,19 @@ cut_legacy() { require_oss_not_behind_pro "$version" "$oss_state" "$pro_state" announce_state "$OSS_REPO" "$version" "$oss_state" announce_state "$PRO_REPO" "$version" "$pro_state" - require_legacy_rcs "$version" "$line" "$oss_state" "$pro_state" + # Both repos before either is tagged. Pro is read at its head before the + # dependency bump, and read again below at the head the bump leaves. + local oss_sha pro_sha oss_args=() pro_args=() + dispatch_preflight "$OSS_REPO" "$line" "$version" "$oss_state" + oss_sha="$PREFLIGHT_SHA" + [[ -n "${FORWARD_SOURCE_BRANCH}" ]] && oss_args=(-f "source_branch=${line}") + dispatch_preflight "$PRO_REPO" "$line" "$version" "$pro_state" + pro_sha="$PREFLIGHT_SHA" + [[ -n "${FORWARD_SOURCE_BRANCH}" ]] && pro_args=(-f "source_branch=${line}") + require_legacy_rcs "$version" "$line" "$oss_state" "$pro_state" "$pro_sha" # Tag OSS first so `go get github.com/loft-sh/vcluster@${version}` resolves the # freshly cut tag during the pro dependency bump below. - ensure_tag "$OSS_REPO" "$line" "$version" "$oss_state" + ensure_tag "$OSS_REPO" "$line" "$version" "$oss_state" 0 "" "$oss_sha" if [[ "$pro_state" == "absent" ]]; then # Bump the vendored OSS dependency on the pro release branch to the tag just # created and wait for the auto-merged PR to land, so the pro tag/build below @@ -1167,51 +994,73 @@ cut_legacy() { # dispatch, preserving the tag-before-dispatch invariant. # By now OSS is tagged and the bump merged, so a pro refusal here is past the # point where the next rc alone would get the version out. - ensure_tag "$PRO_REPO" "$line" "$version" "$pro_state" bump partial - ensure_dispatch "$OSS_REPO" "$version" "$oss_state" + if [[ "$pro_state" == "absent" && "${DRY_RUN:-true}" != "true" ]]; then + # The bump wait can be long, and whatever else lands on the branch in the + # meantime is tagged with it, so the head it left is checked too. + pro_sha="$(head_of "$PRO_REPO" "$line")" || exit 1 + require_dispatchable "$PRO_REPO" "$pro_sha" "$WORKFLOW" "$version" "$line" \ + "${OSS_REPO} is already tagged ${version} and the dependency bump has merged into ${PRO_REPO} ${line}; nothing was tagged in ${PRO_REPO}." + pro_args=() + [[ -n "${FORWARD_SOURCE_BRANCH}" ]] && pro_args=(-f "source_branch=${line}") + fi + ensure_tag "$PRO_REPO" "$line" "$version" "$pro_state" bump partial "$pro_sha" + ensure_dispatch "$OSS_REPO" "$version" "$oss_state" "${oss_args[@]}" # OSS is now building. This sequence is non-atomic, but it is resumable: if the # pro dispatch below fails, re-run this action with the same version. It reads # the state back, sees OSS dispatched, and picks up at the pro dispatch alone. if [[ "${DRY_RUN:-true}" != "true" ]]; then echo "::notice::${OSS_REPO} dispatched for ${version}. If the ${PRO_REPO} dispatch below fails, just re-run this action with the same version - it resumes and will not re-dispatch ${OSS_REPO}." fi - ensure_dispatch "$PRO_REPO" "$version" "$pro_state" + ensure_dispatch "$PRO_REPO" "$version" "$pro_state" "${pro_args[@]}" } -cut_monorepo() { - local version="$1" target="$2" state - echo "Routing ${version} -> monorepo (target ${target}); dispatch: ${PRO_REPO} only" - # The target is resolved from the suffix matrix, so it must already exist: - # stable/rc name a vX.Y branch that has to be cut first, alpha/beta name main. - # Refusing to guess is the point - never fall back to a different branch. - require_branch "$PRO_REPO" "$target" +# cut_pro_only - tag in pro and dispatch pro's +# release.yaml at the tag. The monorepo and feature-prerelease paths differ +# only in how they chose . +cut_pro_only() { + local version="$1" branch="$2" state + require_branch "$PRO_REPO" "$branch" release_state_of "$PRO_REPO" "$version"; state="$RELEASE_STATE" guard_double_cut "$version" "${PRO_REPO}=${state}" announce_state "$PRO_REPO" "$version" "$state" - ensure_tag "$PRO_REPO" "$target" "$version" "$state" + dispatch_preflight "$PRO_REPO" "$branch" "$version" "$state" + ensure_tag "$PRO_REPO" "$branch" "$version" "$state" 0 "" "$PREFLIGHT_SHA" local dispatch_args=() [[ -n "${TRIGGERED_BY}" ]] && dispatch_args=(-f "triggered_by=${TRIGGERED_BY}") + [[ -n "${FORWARD_SOURCE_BRANCH}" ]] && dispatch_args+=(-f "source_branch=${branch}") ensure_dispatch "$PRO_REPO" "$version" "$state" "${dispatch_args[@]}" } +cut_monorepo() { + local version="$1" target="$2" + echo "Routing ${version} -> monorepo (target ${target}); dispatch: ${PRO_REPO} only" + # The target is resolved from the suffix matrix, so it must already exist: + # stable/rc name a vX.Y branch that has to be cut first, alpha/beta name main. + # Refusing to guess is the point - never fall back to a different branch. + cut_pro_only "$version" "$target" +} + # cut_feature_prerelease - -next / -next.internal are # cut from a short-lived feature branch and only ever build pro (they are # prereleases of a future, monorepo-era line). Bypasses the era fan-out. cut_feature_prerelease() { - local version="$1" feature="$2" state + local version="$1" feature="$2" echo "Routing ${version} -> feature-branch prerelease (source ${feature}); dispatch: ${PRO_REPO} only" - require_branch "$PRO_REPO" "$feature" - release_state_of "$PRO_REPO" "$version"; state="$RELEASE_STATE" - guard_double_cut "$version" "${PRO_REPO}=${state}" - announce_state "$PRO_REPO" "$version" "$state" - ensure_tag "$PRO_REPO" "$feature" "$version" "$state" - local dispatch_args=() - [[ -n "${TRIGGERED_BY}" ]] && dispatch_args=(-f "triggered_by=${TRIGGERED_BY}") - ensure_dispatch "$PRO_REPO" "$version" "$state" "${dispatch_args[@]}" + cut_pro_only "$version" "$feature" } main() { - local raw_version="${INPUT_VERSION:?INPUT_VERSION is required}" version era line raw_dry_run + local raw_version version era line raw_dry_run + # Checked rather than `${INPUT_VERSION:?}`, whose abort is a bare bash line + # with no ::error::. GitHub does not enforce `required: true` on a composite + # action input. + if [[ -z "${INPUT_VERSION:-}" ]]; then + echo "::error::version is required: the release to cut, e.g. v0.37.1. Nothing was tagged." >&2 + exit 1 + fi + # Flattened where it is read, so no message quoting it back can carry a + # forged second workflow command. + raw_version="$(flatten "${INPUT_VERSION}")" # Canonicalize before ANY consumer sees it: the tag name, the double-cut probe # and every routing decision must all agree on one spelling. Echo the rewrite # so the run log shows exactly which tag is about to be created. @@ -1228,7 +1077,7 @@ main() { # value (empty, typo, "yes", "1", wrong case, stray whitespace) stays in # dry-run, so a misconfigured caller can never accidentally fire a real # cross-repo release. - raw_dry_run="${INPUT_DRY_RUN:-true}" + raw_dry_run="$(flatten "${INPUT_DRY_RUN:-true}")" case "${raw_dry_run,,}" in false) DRY_RUN="false" ;; true) DRY_RUN="true" ;; @@ -1241,9 +1090,16 @@ main() { local suffix source_branch target suffix="$(classify_suffix "$version")" || exit 1 - source_branch="${INPUT_SOURCE_BRANCH:-}" + source_branch="$(trim "$(flatten "${INPUT_SOURCE_BRANCH:-}")")" + # Gated before anything reads it: the routing guards compare it as a string + # and every consumer then puts it in a URL. + if [[ -n "$source_branch" ]] && ! validate_branch "$source_branch"; then + exit 1 + fi line="$(derive_line "$version")" echo "vcluster-release: version=${version} suffix=${suffix} source-branch=${source_branch:-} dry_run=${DRY_RUN} cutover=${CUTOVER}" + # Checked before any API call, so a runner without the right yq fails fast. + require_yq # Feature-branch prereleases (-next/-next.internal) short-circuit the era # routing: they are cut from a short-lived feature branch and always build pro. diff --git a/.github/actions/vcluster-release/test/vcluster-release.bats b/.github/actions/vcluster-release/test/vcluster-release.bats index 67c031f8..972eb08d 100644 --- a/.github/actions/vcluster-release/test/vcluster-release.bats +++ b/.github/actions/vcluster-release/test/vcluster-release.bats @@ -5,6 +5,16 @@ # and network-free. branch_exists / guard / create_tag / dispatch and main are # exercised with a configurable `gh` stub on PATH, so no real API call is made. +# The dispatch preflight reads workflows with the real yq, so a machine with the +# python yq wrapper first on PATH would fail most of this suite for no clear +# reason. Stop once, up front, instead. +setup_file() { + if ! (source "${BATS_TEST_DIRNAME}/../src/vcluster-release.sh" && require_yq); then + echo "these tests need the same yq the action does (see the error above)" >&2 + return 1 + fi +} + setup() { SCRIPT="${BATS_TEST_DIRNAME}/../src/vcluster-release.sh" source "$SCRIPT" @@ -58,6 +68,12 @@ if [[ "$sub" == "api" ]]; then # The tag endpoint is used both ways, so the stub has to distinguish them. wants_status=0 for _a in "$@"; do case "$_a" in --silent|-i) wants_status=1 ;; esac; done + # release-lib's api_read calls `gh api -i --jq ` without + # --silent: the status line, a blank line, then the filtered value. + api_read_mode=0 + if [[ " $* " == *" -i "* && " $* " != *" --silent "* ]]; then api_read_mode=1; fi + # value_out - print a read's value the way the caller asked for it. + value_out() { if [ "$api_read_mode" -eq 1 ]; then printf 'HTTP/2.0 200 OK\n\n'; fi; echo "$1"; } # emit_status - print an HTTP status line and exit like # real gh. Real gh prints the status line to stdout for 200 AND 404, but exits # non-zero on 404. GH_STUB_TRANSIENT=1 simulates a network/auth failure (no @@ -104,7 +120,7 @@ if [[ "$sub" == "api" ]]; then repos/*/git/ref/tags/*) # Singular endpoint: exact match, 404 otherwise (mirrors the real API). rest="${path#repos/}"; repo="${rest%%/git/ref/tags/*}"; tag="${rest##*/git/ref/tags/}" - if [[ "$wants_status" -eq 0 ]]; then + if [[ "$wants_status" -eq 0 || "$api_read_mode" -eq 1 ]]; then # release_state / the barrier resolving the tag to a commit. Emulates # --jq '"\(.object.sha) \(.object.type)"'. GH_STUB_TAG_ANNOTATED makes it # a tag OBJECT, which must then be peeled via git/tags/. @@ -122,12 +138,23 @@ if [[ "$sub" == "api" ]]; then fi # Resolvable if the tag pre-existed OR this run just created it - the # post-dispatch barrier resolves the tag it has only just written. + # GH_STUB_TAG_READ shapes only the preflight's api_read of a tag that + # release_state found: "gone" as if deleted in between, "tree" for a + # tag that does not lead to a commit. + if [ "$api_read_mode" -eq 1 ]; then + case "${GH_STUB_TAG_READ:-}" in + gone) echo "HTTP/2.0 404 Not Found"; exit 1 ;; + tree) value_out "4b825dc642cb6eb9a060e54bf8d69288fbee4904 tree"; exit 0 ;; + esac + fi if contains "${GH_STUB_TAGS:-}" "${repo}:${tag}" || [ -f "$(dirname "$0")/tag_created" ]; then if [[ "${GH_STUB_TAG_ANNOTATED:-}" == "1" ]]; then - echo "aaaabbbbccccdddd tag" + value_out "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb tag" else - echo "deadbeefcafe1234 commit" + value_out "deadbeefcafe1234deadbeefcafe1234deadbeef commit" fi + elif [ "$api_read_mode" -eq 1 ]; then + echo "HTTP/2.0 404 Not Found"; exit 1 fi exit 0 fi @@ -175,6 +202,30 @@ if [[ "$sub" == "api" ]]; then if [ "$_active" -eq 1 ] && [[ "${GH_STUB_RUN_SHA_MISMATCH:-}" != "1" ]]; then _total=1; fi echo "${_total} ${_active}" exit 0 ;; + repos/*/contents/.github/workflows/*) + # The preflight reads the release.yaml a dispatch would run, raw. Unset, + # every line's file is dispatchable and declares triggered_by only, which + # is what every live line accepts. GH_STUB_WORKFLOW_SOURCE_BRANCH=optional + # or required declares source_branch as well. GH_STUB_WORKFLOW_FILE serves + # that file instead, GH_STUB_WORKFLOW_READ_FAILS=1 fails the read. + if [[ "${GH_STUB_WORKFLOW_READ_FAILS:-}" == "1" ]]; then echo "gh: dial tcp: lookup api.github.com" >&2; exit 1; fi + # GH_STUB_WORKFLOW_FILE_REPO limits that file to one repo, and + # GH_STUB_WORKFLOW_FILE_REF to one ?ref= value. + rest="${path#repos/}"; repo="${rest%%/contents/*}"; wf_ref="${path##*\?ref=}" + if [[ -n "${GH_STUB_WORKFLOW_FILE:-}" && "${GH_STUB_WORKFLOW_FILE_REPO:-$repo}" == "$repo" && + "${GH_STUB_WORKFLOW_FILE_REF:-$wf_ref}" == "$wf_ref" ]]; then + cat "$GH_STUB_WORKFLOW_FILE"; exit 0 + fi + printf 'on:\n workflow_dispatch:\n inputs:\n triggered_by:\n type: string\n required: false\n' + case "${GH_STUB_WORKFLOW_SOURCE_BRANCH:-}" in + optional) printf ' source_branch:\n type: string\n required: false\n' ;; + required) printf ' source_branch:\n type: string\n required: true\n' ;; + esac + exit 0 ;; + repos/*/actions/workflows/*) + # Whether GitHub would run the workflow at all, read through api_get, so + # answered with a status line, a blank line and the --jq '.state' value. + echo "HTTP/2.0 200 OK"; echo ""; echo "${GH_STUB_WORKFLOW_STATE:-active}"; exit 0 ;; repos/*/contents/go.mod*) # bump_landed_at_ref reads go.mod off a branch or tag. The script # pipes .content through `base64 -d`, so emit base64 like the real API. @@ -207,8 +258,10 @@ if [[ "$sub" == "api" ]]; then esac | base64 -w0 exit 0 ;; repos/*/git/tags/*) - # Peeling an annotated tag object to the commit it points at. - echo "deadbeefcafe1234"; exit 0 ;; + # Peeling an annotated tag object to the commit it points at. api_read + # asks for " ", the plain read for the sha alone. + if [ "$api_read_mode" -eq 1 ]; then value_out "deadbeefcafe1234deadbeefcafe1234deadbeef commit"; exit 0; fi + echo "deadbeefcafe1234deadbeefcafe1234deadbeef"; exit 0 ;; repos/*/commits/*) # The files a commit changes, as --jq '.files[].filename' prints them. # Unset, every commit is a real bump's three files. GH_STUB_COMMIT_FILES @@ -246,11 +299,22 @@ if [[ "$sub" == "api" ]]; then [[ "$c_key" == "$repo" ]] && answer="$c_status $c_ahead $c_behind" done echo "$answer"; exit 0 ;; + repos/*/compare/*) + # require_tag_on_target asking whether a resumed tag is on its branch, as + # api_read's --jq '.status'. GH_STUB_TARGET_COMPARE sets the answer, for + # GH_STUB_TARGET_COMPARE_REPO only when that is set; otherwise the tag is + # behind the branch head, i.e. on it. + rest="${path#repos/}"; repo="${rest%%/compare/*}" + if [[ -n "${GH_STUB_TARGET_COMPARE:-}" && "${GH_STUB_TARGET_COMPARE_REPO:-$repo}" == "$repo" ]]; then + value_out "$GH_STUB_TARGET_COMPARE"; exit 0 + fi + value_out "behind"; exit 0 ;; repos/*/git/ref/heads/*) # Singular endpoint: exact match only, mirroring the real API. # GH_STUB_HEAD_MISSING=1 makes only this probe 404, simulating a branch # deleted between require_branch and create_tag. if [[ "${GH_STUB_HEAD_MISSING:-}" == "1" ]]; then + [ "$api_read_mode" -eq 1 ] && echo "HTTP/2.0 404 Not Found" echo "gh: Not Found (HTTP 404)" >&2; exit 1 fi # GH_STUB_HEAD_EMPTY=1 succeeds with no sha: `--jq '.object.sha // empty'` @@ -263,9 +327,9 @@ if [[ "$sub" == "api" ]]; then # the bump workflow has been dispatched, as its merged PR does. rest="${path#repos/}"; repo="${rest%%/git/ref/heads/*}" if [[ -n "${GH_STUB_HEAD_AFTER_BUMP:-}" && -f "$(dirname "$0")/bump_dispatched" && "${GH_STUB_HEAD_AFTER_BUMP%%:*}" == "$repo" ]]; then - echo "${GH_STUB_HEAD_AFTER_BUMP#*:}"; exit 0 + value_out "${GH_STUB_HEAD_AFTER_BUMP#*:}"; exit 0 fi - echo "deadbeefcafe"; exit 0 ;; + value_out "deadbeefcafe"; exit 0 ;; repos/*/git/refs/heads/*) # Plural endpoint: falls back to an array of prefix matches when the exact # ref is gone, so `--jq .object.sha` errors out. Kept so a regression from @@ -318,6 +382,13 @@ fi if [[ "$sub" == "workflow" && "${1:-}" == "run" ]]; then [[ "${2:-}" == "release-bump-vcluster.yaml" ]] && : > "$(dirname "$0")/bump_dispatched" + # GH_STUB_DISPATCH_FAILS=1 rejects the release.yaml dispatch, as GitHub's 422 + # does for an input the workflow does not declare. + if [[ "${2:-}" == "release.yaml" && "${GH_STUB_DISPATCH_FAILS:-}" == "1" ]]; then + echo "could not create workflow dispatch event: HTTP 422: Unexpected inputs provided" >&2; exit 1 + fi + # Current gh prints the started run's URL. + [[ "${2:-}" == "release.yaml" ]] && echo "https://github.com/stub/actions/runs/4242" exit 0 fi @@ -1081,7 +1152,7 @@ EOF @test "main requires INPUT_VERSION" { run main [ "$status" -ne 0 ] - [[ "$output" == *"INPUT_VERSION is required"* ]] + [[ "$output" == *"::error::version is required"* ]] } # ---- main: live (non-dry-run) path ---- @@ -1299,9 +1370,12 @@ EOF } @test "resolve_target: rc accepts main or the line branch, rejects a foreign source" { - # The empty case is the network-free fallback; main() has already narrowed an - # rc through resolve_rc_source, which refuses main once vX.Y exists. - run resolve_target "rc" "" "v0.40"; [ "$output" = "main" ] + # main() narrows an rc through resolve_rc_source first, which refuses main + # once vX.Y exists. An empty source means that probe was skipped, so it is + # refused rather than defaulted to main past the refusal. + run resolve_target "rc" "" "v0.40" + [ "$status" -ne 0 ] + [[ "$output" == *"resolve_rc_source first"* ]] run resolve_target "rc" "main" "v0.40"; [ "$output" = "main" ] run resolve_target "rc" "v0.40" "v0.40"; [ "$output" = "v0.40" ] run resolve_target "rc" "my-feature" "v0.40" @@ -1952,9 +2026,9 @@ JSON export GH_STUB_CALL_LOG="${STUB_DIR}/calls" INPUT_VERSION="v0.37.3" INPUT_DRY_RUN="false" run main [ "$status" -eq 1 ] - [[ "$output" == *"(deadbeefcafe1234) is 1 commit(s) past v0.37.3-rc.2"* ]] + [[ "$output" == *"(deadbeefcafe1234deadbeefcafe1234deadbeef) is 1 commit(s) past v0.37.3-rc.2"* ]] [[ "$output" != *"dispatched release.yaml"* ]] - grep -q "\.\.\.deadbeefcafe1234?" "${STUB_DIR}/calls" + grep -q "\.\.\.deadbeefcafe1234deadbeefcafe1234deadbeef?" "${STUB_DIR}/calls" } @test "monorepo resume: an already-dispatched stable is not checked" { @@ -2102,33 +2176,38 @@ JSON [ "$(grep -c '/compare/refs/tags/' "${STUB_DIR}/calls")" -eq 2 ] } -@test "legacy: an rc cut reads each branch head once, to tag it" { +@test "legacy: an rc cut reads OSS's head once and pro's before and after the bump" { + # OSS is tagged at the head the preflight read. Pro is tagged at its head + # after the bump, which moves it. export GH_STUB_BRANCHES="loft-sh/vcluster:v0.36 loft-sh/vcluster-pro:v0.36" export GH_STUB_GOMOD_VERSION="v0.36.4-rc.1" export GH_STUB_CALL_LOG="${STUB_DIR}/calls" INPUT_VERSION="v0.36.4-rc.1" INPUT_DRY_RUN="false" run main [ "$status" -eq 0 ] - [ "$(grep -c '/git/ref/heads/' "${STUB_DIR}/calls")" -eq 2 ] + [ "$(grep -c '^repos/loft-sh/vcluster/git/ref/heads/' "${STUB_DIR}/calls")" -eq 1 ] + [ "$(grep -c '^repos/loft-sh/vcluster-pro/git/ref/heads/' "${STUB_DIR}/calls")" -eq 2 ] } @test "monorepo resume: an rc resume does not re-read its tag for the stable check" { # Only a stable is held to an rc, so one failed read of an rc's tag after - # release_state must not stop its resume. + # release_state and the preflight must not stop its resume. export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" - export GH_STUB_TAG_VALUE_FAIL_AT=2 + export GH_STUB_TAG_VALUE_FAIL_AT=3 INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main [ "$status" -eq 0 ] [[ "$output" == *"dispatched release.yaml in loft-sh/vcluster-pro at v0.37.3-rc.3"* ]] } -@test "monorepo: a prerelease dry-run reads no branch head" { - # Nothing is tagged and no rc is checked, so a preview has no use for it. +@test "monorepo: a prerelease dry-run reads the branch head for the preflight" { + # The preview checks the release.yaml at the commit it would tag, so a head + # it cannot read stops it the way it would stop a real cut. export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" export GH_STUB_HEAD_MISSING=1 INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="true" run main - [ "$status" -eq 0 ] - [[ "$output" == *"[dry-run] gh api -X POST repos/loft-sh/vcluster-pro/git/refs -f ref=refs/tags/v0.37.3-rc.3"* ]] + [ "$status" -ne 0 ] + [[ "$output" == *"could not read HEAD of branch 'v0.37'"* ]] + [[ "$output" != *"[dry-run] gh api -X POST"* ]] } @test "monorepo resume: a refused stable tag is named as the cause and left in place" { @@ -2140,9 +2219,9 @@ JSON export GH_STUB_RC_COMPARE="loft-sh/vcluster-pro:behind:0:3" INPUT_VERSION="v0.37.3" INPUT_DRY_RUN="false" run main [ "$status" -eq 1 ] - [[ "$output" == *"refusing to resume v0.37.3 in loft-sh/vcluster-pro: tag v0.37.3 (deadbeefcafe1234) is 3 commit(s) behind v0.37.3-rc.2"* ]] + [[ "$output" == *"refusing to resume v0.37.3 in loft-sh/vcluster-pro: tag v0.37.3 (deadbeefcafe1234deadbeefcafe1234deadbeef) is 3 commit(s) behind v0.37.3-rc.2"* ]] [[ "$output" == *"Another rc alone will not fix this here"* ]] - [[ "$output" == *"compare/deadbeefcafe1234...v0.37.3-rc.2"* ]] + [[ "$output" == *"compare/deadbeefcafe1234deadbeefcafe1234deadbeef...v0.37.3-rc.2"* ]] [[ "$output" != *"Cut v0.37.3-rc."* ]] [[ "$output" != *"Cut v0.37.3-rc.3, then"* ]] } @@ -2304,7 +2383,7 @@ JSON export GH_STUB_CALL_LOG="${STUB_DIR}/calls" INPUT_VERSION="v0.36.3" INPUT_DRY_RUN="false" run main [ "$status" -eq 1 ] - [[ "$output" == *"refusing to build on loft-sh/vcluster v0.36.3: tag v0.36.3 (deadbeefcafe1234) is 1 commit(s) past v0.36.3-rc.1"* ]] + [[ "$output" == *"refusing to build on loft-sh/vcluster v0.36.3: tag v0.36.3 (deadbeefcafe1234deadbeefcafe1234deadbeef) is 1 commit(s) past v0.36.3-rc.1"* ]] ! grep -q "pulls" "${STUB_DIR}/calls" [ ! -f "${STUB_DIR}/tag_created" ] } @@ -2345,7 +2424,7 @@ JSON export GH_STUB_GOMOD_VERSION="v0.36.3" INPUT_VERSION="v0.36.3" INPUT_DRY_RUN="false" run main [ "$status" -eq 1 ] - [[ "$output" == *"refusing to resume v0.36.3 in loft-sh/vcluster-pro: tag v0.36.3 (deadbeefcafe1234) is 2 commit(s) past v0.36.3-rc.1 (at most 1 allowed)"* ]] + [[ "$output" == *"refusing to resume v0.36.3 in loft-sh/vcluster-pro: tag v0.36.3 (deadbeefcafe1234deadbeefcafe1234deadbeef) is 2 commit(s) past v0.36.3-rc.1 (at most 1 allowed)"* ]] [[ "$output" != *"dispatched release.yaml"* ]] } @@ -2418,7 +2497,7 @@ JSON export GH_STUB_RC_COMPARE="loft-sh/vcluster:ahead:1:0" INPUT_VERSION="v0.36.3" INPUT_DRY_RUN="false" run main [ "$status" -eq 1 ] - [[ "$output" == *"refusing to build on loft-sh/vcluster v0.36.3: tag v0.36.3 (deadbeefcafe1234) is 1 commit(s) past"* ]] + [[ "$output" == *"refusing to build on loft-sh/vcluster v0.36.3: tag v0.36.3 (deadbeefcafe1234deadbeefcafe1234deadbeef) is 1 commit(s) past"* ]] [[ "$output" != *"refusing to resume v0.36.3 in loft-sh/vcluster:"* ]] } @@ -2426,3 +2505,367 @@ JSON run announce_state loft-sh/vcluster-pro v0.37.3 absent [[ "$output" == *"will tag and dispatch unless a check below refuses it"* ]] } + +# ---- dispatch preflight: release.yaml is checked before anything is tagged ---- + +@test "preflight: a release.yaml with no workflow_dispatch is refused before the tag" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.40" + printf 'on:\n push:\n tags: ["v*"]\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" + INPUT_VERSION="v0.40.1-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"has no workflow_dispatch trigger"* ]] + [[ "$output" == *"Nothing was tagged"* ]] + [ ! -f "${STUB_DIR}/tag_created" ] +} + +@test "preflight: a release.yaml that cannot be read is refused before the tag" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + export GH_STUB_WORKFLOW_READ_FAILS=1 + INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"could not read .github/workflows/release.yaml at 'main' in loft-sh/vcluster-pro"* ]] + [ ! -f "${STUB_DIR}/tag_created" ] +} + +@test "preflight: a disabled release.yaml is refused before the tag" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + export GH_STUB_WORKFLOW_STATE="disabled_manually" + INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"is disabled_manually, not active"* ]] + [ ! -f "${STUB_DIR}/tag_created" ] +} + +@test "preflight: a monorepo release.yaml without triggered_by is refused before the tag" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + printf 'on:\n workflow_dispatch:\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" + TRIGGERED_BY="bob" INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"declares no 'triggered_by' input"* ]] + [ ! -f "${STUB_DIR}/tag_created" ] +} + +@test "preflight: the monorepo cut reads release.yaml at the commit it tags" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + export GH_STUB_CALL_LOG="${STUB_DIR}/calls" + INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + grep -q '^repos/loft-sh/vcluster-pro/contents/.github/workflows/release.yaml?ref=deadbeefcafe$' "${STUB_DIR}/calls" + # One head read, shared by the preflight and the tag. + [ "$(grep -c '/git/ref/heads/' "${STUB_DIR}/calls")" -eq 1 ] +} + +@test "preflight: legacy needs no triggered_by, and checks pro before OSS is tagged" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + printf 'on:\n workflow_dispatch:\n release:\n types: [created]\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" GH_STUB_WORKFLOW_FILE_REPO="loft-sh/vcluster-pro" + TRIGGERED_BY="carol" INPUT_VERSION="v0.35.4-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"in loft-sh/vcluster-pro triggers on more than workflow_dispatch (release)"* ]] + [ ! -f "${STUB_DIR}/tag_created" ] + [ ! -f "${STUB_DIR}/bump_dispatched" ] +} + +@test "preflight: a legacy release.yaml with no inputs passes with TRIGGERED_BY set" { + # Live legacy lines declare no triggered_by, and their dispatches pass none. + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + printf 'on:\n workflow_dispatch:\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" + TRIGGERED_BY="carol" INPUT_VERSION="v0.35.4" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"[dry-run] gh workflow run release.yaml --repo loft-sh/vcluster-pro --ref v0.35.4"* ]] +} + +@test "preflight: a resumed tag whose commit cannot be resolved is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_TAG_VALUE_FAIL_AT=2 + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"gh said: gh: HTTP 502: Bad Gateway. The cut cannot read the release.yaml a dispatch would run, and nothing was dispatched in loft-sh/vcluster-pro. Re-run the cut once GitHub answers."* ]] + [[ "$output" != *"dispatched release.yaml"* ]] +} + +# ---- source_branch: forwarded only where release.yaml declares it ---- + +@test "source_branch: not passed when the monorepo release.yaml does not declare it" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.40" + TRIGGERED_BY="bob" INPUT_VERSION="v0.40.1" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--ref v0.40.1 -f triggered_by=bob"* ]] + [[ "$output" != *"source_branch"* ]] +} + +@test "source_branch: the monorepo dispatch passes the branch it tagged" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.40" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="required" + TRIGGERED_BY="bob" INPUT_VERSION="v0.40.1" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--ref v0.40.1 -f triggered_by=bob -f source_branch=v0.40"* ]] +} + +@test "source_branch: an alpha passes main" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="optional" + INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--ref v0.40.0-alpha.1 -f source_branch=main"* ]] +} + +@test "source_branch: a feature prerelease passes its feature branch" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:feat/x" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="optional" + INPUT_VERSION="v0.40.0-next.1" INPUT_SOURCE_BRANCH="feat/x" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--ref v0.40.0-next.1 -f source_branch=feat/x"* ]] +} + +@test "source_branch: a resumed rc on its line leaves out an optional input" { + # It may have been tagged on main before v0.37 was branched from a newer main. + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="optional" + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"not passing source_branch: v0.37.3-rc.3 may have been tagged on main"* ]] + [[ "$output" != *"-f source_branch"* ]] +} + +@test "source_branch: a resumed rc on its line still passes a required input" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="required" + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--ref v0.37.3-rc.3 -f source_branch=v0.37"* ]] +} + +@test "source_branch: a legacy line passes it to each repo that declares it" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="optional" + TRIGGERED_BY="carol" INPUT_VERSION="v0.35.4" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--repo loft-sh/vcluster --ref v0.35.4 -f source_branch=v0.35"* ]] + [[ "$output" == *"--repo loft-sh/vcluster-pro --ref v0.35.4 -f source_branch=v0.35"* ]] + [[ "$output" != *"triggered_by"* ]] +} + +# ---- a failed dispatch says how to resume ---- + +@test "dispatch: a failed monorepo dispatch names the tag left behind and the re-run" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + export GH_STUB_DISPATCH_FAILS=1 + INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="false" run bash "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"could not dispatch release.yaml in loft-sh/vcluster-pro at v0.40.0-alpha.1, so v0.40.0-alpha.1 is tagged there with no build. Re-run the cut with the same version"* ]] + [[ "$output" == *"gh said: could not create workflow dispatch event: HTTP 422"* ]] +} + +@test "dispatch: a failed feature-prerelease dispatch says the same" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:feat/x" + export GH_STUB_DISPATCH_FAILS=1 + INPUT_VERSION="v0.40.0-next.1" INPUT_SOURCE_BRANCH="feat/x" INPUT_DRY_RUN="false" run bash "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"could not dispatch release.yaml in loft-sh/vcluster-pro at v0.40.0-next.1"* ]] +} + +@test "dispatch: a failed legacy OSS dispatch does not go on to pro" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_GOMOD_VERSION="v0.35.4-rc.1" + export GH_STUB_DISPATCH_FAILS=1 + INPUT_VERSION="v0.35.4-rc.1" INPUT_DRY_RUN="false" run bash "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"could not dispatch release.yaml in loft-sh/vcluster at v0.35.4-rc.1"* ]] + [[ "$output" != *"in loft-sh/vcluster-pro at v0.35.4-rc.1, so"* ]] +} + +# ---- inputs shared with platform-release ---- + +@test "main: a source-branch that is not a branch name is refused before any read" { + export GH_STUB_CALL_LOG="${STUB_DIR}/calls" + INPUT_VERSION="v0.40.0-next.1" INPUT_SOURCE_BRANCH="main?" INPUT_DRY_RUN="true" run main + [ "$status" -ne 0 ] + [[ "$output" == *"source-branch 'main?' is not a valid branch name"* ]] + [ ! -s "${STUB_DIR}/calls" ] +} + +@test "main: a version with a leading zero is refused" { + INPUT_VERSION="v0.37.01" INPUT_DRY_RUN="true" run main + [ "$status" -ne 0 ] + [[ "$output" == *"version 'v0.37.01' is not a valid release version"* ]] +} + +# ---- review fixes ---- + +@test "legacy: pro's release.yaml is checked again at the head the bump left" { + # The bump wait can be long, and whatever lands on pro meanwhile is tagged. + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_HEAD_AFTER_BUMP="loft-sh/vcluster-pro:c0ffee0000000000000000000000000000000000" + printf 'on:\n workflow_dispatch:\n push:\n tags: ["v*"]\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" GH_STUB_WORKFLOW_FILE_REPO="loft-sh/vcluster-pro" + export GH_STUB_WORKFLOW_FILE_REF="c0ffee0000000000000000000000000000000000" + export GH_STUB_CALL_LOG="${STUB_DIR}/calls" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"release.yaml at 'v0.35' in loft-sh/vcluster-pro triggers on more than workflow_dispatch (push)"* ]] + [[ "$output" == *"loft-sh/vcluster is already tagged v0.35.5-rc.1 and the dependency bump has merged into loft-sh/vcluster-pro v0.35; nothing was tagged in loft-sh/vcluster-pro."* ]] + [[ "$output" != *"Nothing was tagged."* ]] + ! grep -q '^repos/loft-sh/vcluster-pro/git/refs$' "${STUB_DIR}/calls" +} + +@test "legacy: a clean bump is tagged at the head the check read" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_HEAD_AFTER_BUMP="loft-sh/vcluster-pro:c0ffee0000000000000000000000000000000000" + export GH_STUB_CALL_LOG="${STUB_DIR}/calls" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + [[ "$output" == *"created tag v0.35.5-rc.1 in loft-sh/vcluster-pro at v0.35 (c0ffee0000000000000000000000000000000000)"* ]] + grep -q '^repos/loft-sh/vcluster-pro/contents/.github/workflows/release.yaml?ref=c0ffee0000000000000000000000000000000000$' "${STUB_DIR}/calls" +} + +@test "resume: a monorepo tag that is not on its branch is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_TARGET_COMPARE="diverged" + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"tag v0.37.3-rc.3 in loft-sh/vcluster-pro points at deadbeefcafe1234deadbeefcafe1234deadbeef, which is not on v0.37 (diverged)"* ]] + [[ "$output" == *"Nothing was dispatched in loft-sh/vcluster-pro, and a re-run never moves an existing tag."* ]] + [[ "$output" != *"delete it by hand"* ]] + [[ "$output" != *"dispatched release.yaml"* ]] +} + +@test "resume: a -next tag re-run from another feature branch is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:feat/b" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.40.0-next.1" + export GH_STUB_TARGET_COMPARE="ahead" + # Declared, so a resume that got past the check would forward feat/b. + export GH_STUB_WORKFLOW_SOURCE_BRANCH="optional" + INPUT_VERSION="v0.40.0-next.1" INPUT_SOURCE_BRANCH="feat/b" INPUT_DRY_RUN="true" run main + [ "$status" -ne 0 ] + [[ "$output" == *"which is not on feat/b (ahead)"* ]] + [[ "$output" != *"source_branch=feat/b"* ]] +} + +@test "resume: a legacy pro tag that is not on its line is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_TAGS="loft-sh/vcluster:v0.35.5-rc.1 loft-sh/vcluster-pro:v0.35.5-rc.1" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_TARGET_COMPARE="diverged" GH_STUB_TARGET_COMPARE_REPO="loft-sh/vcluster-pro" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"tag v0.35.5-rc.1 in loft-sh/vcluster-pro points at"*"which is not on v0.35 (diverged)"* ]] + [[ "$output" != *"dispatched release.yaml"* ]] +} + +@test "resume: a tag on its branch resumes" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + [[ "$output" == *"dispatched release.yaml in loft-sh/vcluster-pro at v0.37.3-rc.3"* ]] +} + +@test "resume: a refusal at an existing tag says the branch cannot change the file" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + printf 'on:\n workflow_dispatch:\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" + TRIGGERED_BY="bob" INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="true" run main + [ "$status" -ne 0 ] + [[ "$output" == *"declares no 'triggered_by' input"* ]] + [[ "$output" == *"The file is read at existing tag v0.37.3-rc.3, which a re-run never moves, so a change on the branch does not reach it."* ]] +} + +@test "resume: a refusal on a fresh cut does not mention an existing tag" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + printf 'on:\n workflow_dispatch:\n' >"${STUB_DIR}/release.yaml" + export GH_STUB_WORKFLOW_FILE="${STUB_DIR}/release.yaml" + TRIGGERED_BY="bob" INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="true" run main + [ "$status" -ne 0 ] + [[ "$output" == *"declares no 'triggered_by' input"* ]] + [[ "$output" != *"existing tag"* ]] +} + +@test "resume: a tag read that fails keeps gh's reason" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_TAG_VALUE_FAIL_AT=2 + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"gh: HTTP 502: Bad Gateway"* ]] + [[ "$output" == *"nothing was dispatched in loft-sh/vcluster-pro"* ]] +} + +@test "resume: a tagged stable reads its tag once before the dispatch" { + # release_state and the preflight read it; the rc check reuses the second. + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3" + export GH_STUB_TAG_VALUE_FAIL_AT=3 + INPUT_VERSION="v0.37.3" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + [[ "$output" == *"dispatched release.yaml in loft-sh/vcluster-pro at v0.37.3"* ]] +} + +@test "legacy: a stable cut reads pro's head once before the bump" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_GOMOD_VERSION="v0.35.5" + export GH_STUB_CALL_LOG="${STUB_DIR}/calls" + INPUT_VERSION="v0.35.5" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [ "$(grep -c '^repos/loft-sh/vcluster-pro/git/ref/heads/' "${STUB_DIR}/calls")" -eq 1 ] +} + +@test "dispatch: a dispatch that starts prints what gh said" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:main" + INPUT_VERSION="v0.40.0-alpha.1" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + [[ "$output" == *"https://github.com/stub/actions/runs/4242"* ]] +} + +@test "resume: a legacy OSS tag that is not on its line is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_TAGS="loft-sh/vcluster:v0.35.5-rc.1 loft-sh/vcluster-pro:v0.35.5-rc.1" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_TARGET_COMPARE="diverged" GH_STUB_TARGET_COMPARE_REPO="loft-sh/vcluster" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"tag v0.35.5-rc.1 in loft-sh/vcluster points at"*"which is not on v0.35 (diverged)"* ]] + [[ "$output" != *"dispatched release.yaml"* ]] +} + +@test "resume: an unexpected comparison names the repo it came from" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_TAGS="loft-sh/vcluster:v0.35.5-rc.1 loft-sh/vcluster-pro:v0.35.5-rc.1" + export GH_STUB_RELEASES="loft-sh/vcluster:v0.35.5-rc.1" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_TARGET_COMPARE="weird" GH_STUB_TARGET_COMPARE_REPO="loft-sh/vcluster-pro" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"unexpected comparison status 'weird' for v0.35.5-rc.1 against v0.35 in loft-sh/vcluster-pro. Nothing was dispatched in loft-sh/vcluster-pro."* ]] +} + +@test "resume: a tag deleted after the state read is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_TAG_READ="gone" + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"tag v0.37.3-rc.3 was in loft-sh/vcluster-pro a moment ago and is gone now. The cut cannot read the release.yaml a dispatch would run, and nothing was dispatched in loft-sh/vcluster-pro. Re-run the cut, which reads the state again."* ]] + [[ "$output" != *"dispatched release.yaml"* ]] +} + +@test "resume: a tag that does not lead to a commit is not dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster-pro:v0.37" + export GH_STUB_TAGS="loft-sh/vcluster-pro:v0.37.3-rc.3" + export GH_STUB_TAG_READ="tree" + INPUT_VERSION="v0.37.3-rc.3" INPUT_DRY_RUN="false" run main + [ "$status" -ne 0 ] + [[ "$output" == *"does not lead to a commit"*"so no build can run from it. The cut cannot read the release.yaml a dispatch would run, and nothing was dispatched in loft-sh/vcluster-pro."* ]] + [[ "$output" != *"Re-run the cut once GitHub answers"* ]] + [[ "$output" != *"dispatched release.yaml"* ]] +} diff --git a/Makefile b/Makefile index 4b7015d0..e620277b 100644 --- a/Makefile +++ b/Makefile @@ -184,7 +184,7 @@ test-release-branch-freeze: ## run release-branch-freeze bats tests test-prerelease-setup: ## run prerelease-setup bats tests bats $(ACTIONS_DIR)/prerelease-setup/test -test-vcluster-release: ## run vcluster-release bats tests +test-vcluster-release: ## run vcluster-release bats tests (requires mikefarah/yq on PATH) bats $(ACTIONS_DIR)/vcluster-release/test/*.bats test-platform-release: ## run platform-release bats tests (requires mikefarah/yq on PATH) diff --git a/README.md b/README.md index 1dc5c9d7..217ae8ef 100644 --- a/README.md +++ b/README.md @@ -494,17 +494,18 @@ See the action README for full details. ### Cut vCluster release Single entry point for cutting a vCluster release on any supported line. The -version string decides the routing (legacy `< v0.36` fans out to both -`loft-sh/vcluster` and `loft-sh/vcluster-pro`; monorepo `>= v0.36` dispatches -`loft-sh/vcluster-pro` only). Creates the tag(s) and dispatches each line's own -`release.yaml`; the GitHub Release is a pipeline output, not a trigger. +version string decides the routing (legacy `< v0.37` fans out to both +`loft-sh/vcluster` and `loft-sh/vcluster-pro`; monorepo `>= v0.37` dispatches +`loft-sh/vcluster-pro` only). Checks each line's `release.yaml` can be +dispatched, then creates the tag(s) and dispatches it; the GitHub Release is a +pipeline output, not a trigger. Needs mikefarah `yq` v4.46.1+ on the runner. **Location:** `.github/actions/vcluster-release` **Usage:** ```yaml -- uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v1 +- uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v2 with: version: ${{ inputs.version }} dry-run: ${{ inputs.dry_run }} From abd3eaf8be94788609ee5378871acb75cafd286b Mon Sep 17 00:00:00 2001 From: Dmytro Sydorov Date: Fri, 16 Oct 2026 20:49:16 +0200 Subject: [PATCH 2/3] test(vcluster-release): cover legacy cuts that pass no source_branch Also covers a failed pro dispatch after OSS was dispatched. --- .../test/vcluster-release.bats | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/actions/vcluster-release/test/vcluster-release.bats b/.github/actions/vcluster-release/test/vcluster-release.bats index 972eb08d..2b7cdcda 100644 --- a/.github/actions/vcluster-release/test/vcluster-release.bats +++ b/.github/actions/vcluster-release/test/vcluster-release.bats @@ -382,6 +382,13 @@ fi if [[ "$sub" == "workflow" && "${1:-}" == "run" ]]; then [[ "${2:-}" == "release-bump-vcluster.yaml" ]] && : > "$(dirname "$0")/bump_dispatched" + # GH_STUB_DISPATCH_LOG records each dispatch's arguments, one per line, for + # the real cuts that print none. + if [ -n "${GH_STUB_DISPATCH_LOG:-}" ]; then printf '%s\n' "$*" >>"$GH_STUB_DISPATCH_LOG"; fi + # Fails the dispatch in GH_STUB_DISPATCH_FAILS_REPO only, when that is set. + if [[ -n "${GH_STUB_DISPATCH_FAILS_REPO:-}" && " $* " != *" --repo ${GH_STUB_DISPATCH_FAILS_REPO} "* ]]; then + GH_STUB_DISPATCH_FAILS="" + fi # GH_STUB_DISPATCH_FAILS=1 rejects the release.yaml dispatch, as GitHub's 422 # does for an input the workflow does not declare. if [[ "${2:-}" == "release.yaml" && "${GH_STUB_DISPATCH_FAILS:-}" == "1" ]]; then @@ -2652,6 +2659,37 @@ JSON [[ "$output" != *"triggered_by"* ]] } +@test "source_branch: a legacy line passes none when release.yaml does not declare it" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + TRIGGERED_BY="carol" INPUT_VERSION="v0.35.4" INPUT_DRY_RUN="true" run main + [ "$status" -eq 0 ] + [[ "$output" == *"--repo loft-sh/vcluster --ref v0.35.4"* ]] + [[ "$output" == *"--repo loft-sh/vcluster-pro --ref v0.35.4"* ]] + [[ "$output" != *"source_branch"* ]] +} + +@test "source_branch: a real legacy cut passes none when release.yaml does not declare it" { + # Pro's arguments are rebuilt after the bump, on a path only a real cut takes. + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_DISPATCH_LOG="${STUB_DIR}/dispatches" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + grep -qx "run release.yaml --repo loft-sh/vcluster --ref v0.35.5-rc.1" "${STUB_DIR}/dispatches" + grep -qx "run release.yaml --repo loft-sh/vcluster-pro --ref v0.35.5-rc.1" "${STUB_DIR}/dispatches" +} + +@test "source_branch: a real legacy cut passes it to each repo that declares it" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_GOMOD_VERSION="v0.35.5-rc.1" + export GH_STUB_WORKFLOW_SOURCE_BRANCH="optional" + export GH_STUB_DISPATCH_LOG="${STUB_DIR}/dispatches" + INPUT_VERSION="v0.35.5-rc.1" INPUT_DRY_RUN="false" run main + [ "$status" -eq 0 ] + grep -qx "run release.yaml --repo loft-sh/vcluster --ref v0.35.5-rc.1 -f source_branch=v0.35" "${STUB_DIR}/dispatches" + grep -qx "run release.yaml --repo loft-sh/vcluster-pro --ref v0.35.5-rc.1 -f source_branch=v0.35" "${STUB_DIR}/dispatches" +} + # ---- a failed dispatch says how to resume ---- @test "dispatch: a failed monorepo dispatch names the tag left behind and the re-run" { @@ -2681,6 +2719,18 @@ JSON [[ "$output" != *"in loft-sh/vcluster-pro at v0.35.4-rc.1, so"* ]] } +@test "dispatch: a failed legacy pro dispatch names pro, after OSS was dispatched" { + export GH_STUB_BRANCHES="loft-sh/vcluster:v0.35 loft-sh/vcluster-pro:v0.35" + export GH_STUB_TAGS="loft-sh/vcluster:v0.35.4-rc.1 loft-sh/vcluster-pro:v0.35.4-rc.1" + export GH_STUB_DISPATCHED="loft-sh/vcluster:v0.35.4-rc.1" + export GH_STUB_GOMOD_VERSION="v0.35.4-rc.1" + export GH_STUB_DISPATCH_FAILS=1 GH_STUB_DISPATCH_FAILS_REPO="loft-sh/vcluster-pro" + INPUT_VERSION="v0.35.4-rc.1" INPUT_DRY_RUN="false" run bash "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"already dispatched at v0.35.4-rc.1 in loft-sh/vcluster;"* ]] + [[ "$output" == *"could not dispatch release.yaml in loft-sh/vcluster-pro at v0.35.4-rc.1, so v0.35.4-rc.1 is tagged there with no build. Re-run the cut with the same version"* ]] +} + # ---- inputs shared with platform-release ---- @test "main: a source-branch that is not a branch name is refused before any read" { From 8771f7cc27061db2999b70aa5448462d5c230606 Mon Sep 17 00:00:00 2001 From: Dmytro Sydorov Date: Fri, 16 Oct 2026 20:58:21 +0200 Subject: [PATCH 3/3] docs(vcluster-release): ship the preflight on v1 The only caller runs on GitHub-hosted Ubuntu, which ships mikefarah yq. --- .github/actions/vcluster-release/README.md | 5 ++--- .github/actions/vcluster-release/src/vcluster-release.sh | 2 +- README.md | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/actions/vcluster-release/README.md b/.github/actions/vcluster-release/README.md index 9756fc50..8831a7f7 100644 --- a/.github/actions/vcluster-release/README.md +++ b/.github/actions/vcluster-release/README.md @@ -264,7 +264,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v2 + - uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v1 with: version: ${{ inputs.version }} dry-run: ${{ inputs.dry_run }} @@ -274,8 +274,7 @@ jobs: ### Runner The runner needs mikefarah `yq` v4.46.1 or later on `PATH`, which GitHub-hosted -Ubuntu runners include. The action checks this before any API call. `v1` -did not read `release.yaml` and needed no `yq`, which is why this is `v2`. +Ubuntu runners include. The action checks this before any API call. ### Auth diff --git a/.github/actions/vcluster-release/src/vcluster-release.sh b/.github/actions/vcluster-release/src/vcluster-release.sh index e510154b..abaeeed4 100755 --- a/.github/actions/vcluster-release/src/vcluster-release.sh +++ b/.github/actions/vcluster-release/src/vcluster-release.sh @@ -101,7 +101,7 @@ TRIGGERED_BY="${TRIGGERED_BY:-}" # Routing, probes and the dispatch preflight, shared with platform-release so # both dispatchers route and refuse the same way. A caller pinning -# vcluster-release/v2 checks out the whole repository at that tag, so the lib +# vcluster-release/v1 checks out the whole repository at that tag, so the lib # read is the one this version was written against. # shellcheck source=.github/actions/release-lib/lib.sh disable=SC1091 source "$(dirname "${BASH_SOURCE[0]}")/../../release-lib/lib.sh" diff --git a/README.md b/README.md index 217ae8ef..aae6b218 100644 --- a/README.md +++ b/README.md @@ -505,7 +505,7 @@ pipeline output, not a trigger. Needs mikefarah `yq` v4.46.1+ on the runner. **Usage:** ```yaml -- uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v2 +- uses: loft-sh/github-actions/.github/actions/vcluster-release@vcluster-release/v1 with: version: ${{ inputs.version }} dry-run: ${{ inputs.dry_run }}