Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/scripts/auto-promote-resolve.sh
Original file line number Diff line number Diff line change
@@ -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:-<empty>}'"
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:-<empty>}'); 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"
80 changes: 16 additions & 64 deletions .github/workflows/auto-promote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:-<empty>}'"
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:-<empty>}'); 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:
Expand Down
184 changes: 184 additions & 0 deletions internal/release/autopromote_resolve_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
Loading