diff --git a/.github/scripts/auto-promote-resolve.sh b/.github/scripts/auto-promote-resolve.sh new file mode 100755 index 00000000..b01a7fc8 --- /dev/null +++ b/.github/scripts/auto-promote-resolve.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Decide whether a completed Fleet E2E run promotes a final vX.Y.Z release, and +# if so, which version. This is the decision core of the auto-promote workflow, +# extracted so it can be exercised in isolation with mock inputs. +# +# Gates, in order: +# 1. conclusion gate - only a success fleet conclusion proceeds. +# 2. full_run gate - a selective run (repos=subset) never promotes. +# 3. rc-only gate - only a vX.Y.Z-rc.N version promotes (not -dryrun.N). +# 4. suffix strip - the final version is the rc with -rc.N removed. +# +# Inputs (environment): +# CONCLUSION - workflow_run.conclusion (default: success). +# WR_HEAD_BRANCH - workflow_run.head_branch (fallback rc source). +# WR_HEAD_SHA - workflow_run.head_sha (fallback rc source). +# GITHUB_REPOSITORY - owner/repo, for the head_sha -> tag fallback. +# GH_TOKEN - token for the head_sha -> tag fallback (gh api). +# GITHUB_OUTPUT - file the resolved outputs are appended to. +# +# Inputs (working directory, both optional): +# full-run.txt - "true" for a full (all repos) fleet run. +# version-under-test.txt - the exact version the fleet pinned every suite to. +# +# Outputs (appended to $GITHUB_OUTPUT): +# promote - "true" only when all gates pass, else "false". +# rc_version - the resolved vX.Y.Z-rc.N (only when promote=true). +# base_version - the final vX.Y.Z to publish (only when promote=true). + +set -euo pipefail + +CONCLUSION="${CONCLUSION:-success}" +WR_HEAD_BRANCH="${WR_HEAD_BRANCH:-}" +WR_HEAD_SHA="${WR_HEAD_SHA:-}" +GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-}" + +# Gate 1: only a green fleet promotes. The calling job already guards on this at +# the job level; re-asserting it here keeps the decision self-contained and lets +# a non-success conclusion short-circuit to a clean no-op. +if [ "$CONCLUSION" != "success" ]; then + echo "::notice::Fleet conclusion was '$CONCLUSION', not success; nothing to promote." + echo "promote=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# Gate 2: read the full_run marker (true only for repos=all/default). Selective +# fleet runs (any subset) must never promote, even if they pass, because only +# full validation is a safe release signal. This gate prevents accidental +# promotion from a maintainer's debug run (e.g. repos=4env). +FULL_RUN="false" +if [ -f full-run.txt ]; then + FULL_RUN=$(tr -d '[:space:]' < full-run.txt) + echo "::notice::Read full-run marker: '$FULL_RUN'" +else + echo "::notice::No full-run marker; assuming pre-selector artifact (full run)." + FULL_RUN="true" +fi + +if [ "$FULL_RUN" != "true" ]; then + echo "::notice::Fleet run was selective (repos=subset), not a full validation. Skipping promotion." + echo "promote=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# Primary: the version-under-test artifact carries the exact resolved version +# the fleet pinned every suite to. Authoritative when present. +RC="" +if [ -f version-under-test.txt ]; then + RC=$(tr -d '[:space:]' < version-under-test.txt) + echo "::notice::Read version-under-test artifact: '${RC:-}'" +fi + +# Fallback: no artifact (a tag-push-triggered fleet predating this handoff). +# Resolve the rc tag the fleet validated the prior way. +if [ -z "$RC" ]; then + echo "::notice::No version-under-test artifact; falling back to head_branch / head_sha." + if printf '%s' "$WR_HEAD_BRANCH" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then + RC="$WR_HEAD_BRANCH" + elif [ -n "$WR_HEAD_SHA" ]; then + # Pick the highest rc tag on the head_sha so selection is deterministic + # regardless of API ordering. + RC=$(gh api "repos/${GITHUB_REPOSITORY}/tags" \ + --jq ".[] | select(.commit.sha == \"$WR_HEAD_SHA\") | .name" \ + | grep -- '-rc\.' | sort -V -r | head -n 1 || true) + else + RC="" + fi +fi + +# Gate 3: only an rc tag of shape vX.Y.Z-rc.N promotes. Anything else (a non-rc +# fleet dispatch, a -dryrun.N tag, an empty resolve) is a clean no-op. +if ! printf '%s' "$RC" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then + echo "::notice::Fleet run was not for a vX.Y.Z-rc.N tag (got '${RC:-}'); nothing to promote." + echo "promote=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# Gate 4: strip the -rc.N suffix to get the final release version. +BASE="${RC%-rc.*}" + +{ + echo "promote=true" + echo "rc_version=$RC" + echo "base_version=$BASE" +} >> "$GITHUB_OUTPUT" +echo "::notice::Green full fleet for $RC -> promoting final $BASE" diff --git a/.github/workflows/auto-promote.yaml b/.github/workflows/auto-promote.yaml index 70a6ec4c..80c72f12 100644 --- a/.github/workflows/auto-promote.yaml +++ b/.github/workflows/auto-promote.yaml @@ -56,6 +56,16 @@ jobs: rc_version: ${{ steps.compute.outputs.rc_version }} base_version: ${{ steps.compute.outputs.base_version }} steps: + # Sparse-checkout only the decision script. The resolve job needs no other + # repo content, and the script reads its marker files from the workspace + # root where download-artifact places them. + - name: Check out auto-promote decision script + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + sparse-checkout: | + .github/scripts/auto-promote-resolve.sh + sparse-checkout-cone-mode: false + # Primary source of truth: the resolved version the fleet validated and # whether it was a full (all repos) or selective (subset) run. Soft failure # (continue-on-error) so a missing artifact falls through to the head_branch @@ -69,78 +79,20 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} + # The decision core (conclusion gate, full_run gate, rc-only gate, suffix + # strip) lives in .github/scripts/auto-promote-resolve.sh so it can be unit + # tested in isolation. Untrusted workflow_run fields flow in via env and are + # only ever read quoted inside the script, never interpolated into a shell. - name: Compute base version to promote id: compute env: # PAT for the head_sha -> tag fallback (a same-repo tags read). We # standardise on the fleet PAT, matching fleet-e2e's resolve step. GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} + CONCLUSION: ${{ github.event.workflow_run.conclusion }} WR_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} WR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - set -euo pipefail - - # Read the full_run marker (true only for repos=all/default). Selective - # fleet runs (any subset) must never promote, even if they pass, because - # only full validation is a safe release signal. This gate prevents - # accidental promotion from a maintainer's debug run (e.g. repos=4env). - FULL_RUN="false" - if [ -f full-run.txt ]; then - FULL_RUN=$(tr -d '[:space:]' < full-run.txt) - echo "::notice::Read full-run marker: '$FULL_RUN'" - else - echo "::notice::No full-run marker; assuming pre-selector artifact (full run)." - FULL_RUN="true" - fi - - if [ "$FULL_RUN" != "true" ]; then - echo "::notice::Fleet run was selective (repos=subset), not a full validation. Skipping promotion." - echo "promote=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Primary: the version-under-test artifact carries the exact resolved - # version the fleet pinned every suite to. Authoritative when present. - RC="" - if [ -f version-under-test.txt ]; then - RC=$(tr -d '[:space:]' < version-under-test.txt) - echo "::notice::Read version-under-test artifact: '${RC:-}'" - fi - - # Fallback: no artifact (a tag-push-triggered fleet predating this - # handoff). Resolve the rc tag the fleet validated the prior way. - if [ -z "$RC" ]; then - echo "::notice::No version-under-test artifact; falling back to head_branch / head_sha." - if printf '%s' "$WR_HEAD_BRANCH" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then - RC="$WR_HEAD_BRANCH" - elif [ -n "$WR_HEAD_SHA" ]; then - # Pick the highest rc tag on the head_sha so selection is - # deterministic regardless of API ordering. - RC=$(gh api "repos/${GITHUB_REPOSITORY}/tags" \ - --jq ".[] | select(.commit.sha == \"$WR_HEAD_SHA\") | .name" \ - | grep -- '-rc\.' | sort -V -r | head -n 1 || true) - else - RC="" - fi - fi - - # Gate: only an rc tag of shape vX.Y.Z-rc.N promotes. Anything else - # (a non-rc fleet dispatch, an empty resolve) is a clean no-op. - if ! printf '%s' "$RC" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then - echo "::notice::Fleet run was not for a vX.Y.Z-rc.N tag (got '${RC:-}'); nothing to promote." - echo "promote=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Strip the -rc.N suffix to get the final release version. - BASE="${RC%-rc.*}" - - { - echo "promote=true" - echo "rc_version=$RC" - echo "base_version=$BASE" - } >> "$GITHUB_OUTPUT" - echo "::notice::Green full fleet for $RC -> promoting final $BASE" + run: bash .github/scripts/auto-promote-resolve.sh # Cut the final tag on the rc's commit and drive GoReleaser to publish it. promote: diff --git a/internal/release/autopromote_resolve_test.go b/internal/release/autopromote_resolve_test.go new file mode 100644 index 00000000..bd3b0a01 --- /dev/null +++ b/internal/release/autopromote_resolve_test.go @@ -0,0 +1,184 @@ +package release + +import ( + "bufio" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// autoPromoteResolveScript locates the auto-promote decision script by walking +// up from the test's working directory until it finds the repo's go.mod, then +// joining the known script path. This keeps the test independent of the +// directory go test happens to run it from. +func autoPromoteResolveScript(t *testing.T) string { + t.Helper() + + dir, err := os.Getwd() + require.NoError(t, err) + + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + break + } + parent := filepath.Dir(dir) + require.NotEqual(t, parent, dir, "reached filesystem root without finding go.mod") + dir = parent + } + + script := filepath.Join(dir, ".github", "scripts", "auto-promote-resolve.sh") + _, err = os.Stat(script) + require.NoError(t, err, "auto-promote-resolve.sh not found at %s", script) + return script +} + +// runAutoPromoteResolve runs the decision script in an isolated temp directory +// with the given marker files and environment, then returns the parsed +// key=value pairs the script appended to $GITHUB_OUTPUT. +func runAutoPromoteResolve(t *testing.T, files, env map[string]string) map[string]string { + t.Helper() + + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not available; skipping shell decision test") + } + + script := autoPromoteResolveScript(t) + workDir := t.TempDir() + + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(workDir, name), []byte(content), 0o600)) + } + + outputPath := filepath.Join(workDir, "github_output") + require.NoError(t, os.WriteFile(outputPath, nil, 0o600)) + + cmd := exec.Command(bash, script) + cmd.Dir = workDir + cmd.Env = append(os.Environ(), "GITHUB_OUTPUT="+outputPath) + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "script failed: %s", out) + + return parseGitHubOutput(t, outputPath) +} + +func parseGitHubOutput(t *testing.T, path string) map[string]string { + t.Helper() + + f, err := os.Open(path) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + result := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + result[key] = value + } + require.NoError(t, scanner.Err()) + return result +} + +// TestAutoPromoteResolve_DecisionMatrix exercises the promote/no-promote +// decision the auto-promote workflow makes when a Fleet E2E run completes. Each +// case feeds mock marker files and workflow_run env and asserts the resolved +// outputs, covering every gate without needing a real release. +func TestAutoPromoteResolve_DecisionMatrix(t *testing.T) { + const fullRun = "full-run.txt" + const versionUnderTest = "version-under-test.txt" + + tests := []struct { + name string + files map[string]string + env map[string]string + wantPromote string + wantRC string + wantBase string + }{ + { + name: "rc tag full green run promotes stripped version", + files: map[string]string{fullRun: "true", versionUnderTest: "v1.2.3-rc.4"}, + env: map[string]string{"CONCLUSION": "success"}, + wantPromote: "true", + wantRC: "v1.2.3-rc.4", + wantBase: "v1.2.3", + }, + { + name: "dryrun version is gated out by the rc-only gate", + files: map[string]string{fullRun: "true", versionUnderTest: "v1.2.3-dryrun.4"}, + env: map[string]string{"CONCLUSION": "success"}, + wantPromote: "false", + }, + { + name: "selective run is gated out by the full_run gate", + files: map[string]string{fullRun: "false", versionUnderTest: "v1.2.3-rc.4"}, + env: map[string]string{"CONCLUSION": "success"}, + wantPromote: "false", + }, + { + name: "non-success fleet conclusion never promotes", + files: map[string]string{fullRun: "true", versionUnderTest: "v1.2.3-rc.4"}, + env: map[string]string{"CONCLUSION": "failure"}, + wantPromote: "false", + }, + { + name: "multi-digit version and rc number strip cleanly", + files: map[string]string{fullRun: "true", versionUnderTest: "v1.20.3-rc.10"}, + env: map[string]string{"CONCLUSION": "success"}, + wantPromote: "true", + wantRC: "v1.20.3-rc.10", + wantBase: "v1.20.3", + }, + { + name: "head_branch fallback resolves the rc when no artifact is present", + files: map[string]string{fullRun: "true"}, + env: map[string]string{"CONCLUSION": "success", "WR_HEAD_BRANCH": "v2.0.0-rc.1"}, + wantPromote: "true", + wantRC: "v2.0.0-rc.1", + wantBase: "v2.0.0", + }, + { + name: "non-rc head_branch with no artifact is a clean no-op", + files: map[string]string{fullRun: "true"}, + env: map[string]string{"CONCLUSION": "success", "WR_HEAD_BRANCH": "main"}, + wantPromote: "false", + }, + { + name: "missing full-run marker defaults to a full run (pre-selector artifact)", + files: map[string]string{versionUnderTest: "v3.4.5-rc.2"}, + env: map[string]string{"CONCLUSION": "success"}, + wantPromote: "true", + wantRC: "v3.4.5-rc.2", + wantBase: "v3.4.5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := runAutoPromoteResolve(t, tt.files, tt.env) + + require.Equal(t, tt.wantPromote, got["promote"], "promote decision") + + if tt.wantPromote != "true" { + require.NotContains(t, got, "rc_version", "no rc_version when not promoting") + require.NotContains(t, got, "base_version", "no base_version when not promoting") + return + } + + require.Equal(t, tt.wantRC, got["rc_version"], "rc_version") + require.Equal(t, tt.wantBase, got["base_version"], "base_version") + }) + } +}