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
107 changes: 101 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ permissions:

jobs:
release:
needs: [validate-agents, resolve-agents, recheck-tag]
runs-on: ubuntu-24.04
# Historically ~2 minutes. Bounded so a hung publish cannot hold the
# tag-agents sync (and the default 6h limit) open.
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -53,7 +57,7 @@ jobs:
fi

validate-agents:
needs: release
needs: resolve-agents
# Permission contract of the called workflow. GitHub validates this at
# parse time, before any job runs or `if:` is evaluated — a called
# workflow may only downgrade the caller's grants, so every permission
Expand Down Expand Up @@ -87,15 +91,42 @@ jobs:
# output), and tag-agents prefers that; this job's resolution is the
# fallback if that output is ever empty, and the input for the
# informational pin-drift check below (#6512).
needs: release
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
agents_sha: ${{ steps.resolve.outputs.sha }}
steps:
# Pinned to the commit, not the tag: the guard below must not run a
# copy of itself fetched through the mutable ref it is checking.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}

# First half of the tag-move guard — see the recheck-tag job.
- name: Verify release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/verify-release-tag.sh

# validate-agents is only a real gate while the functional tests
# actually run. In the called workflow an empty E2E_GCP_WIF_PROVIDER
# makes the "Check for secrets" step log a warning, skip GCP auth and
# every test step, and still report success — which, now that the
# binary is published only after that gate, would ship a release that
# validated nothing. Fail the release loudly instead of silently
# degrading to no coverage.
- name: Verify agents gate secrets
env:
WIF_PROVIDER: ${{ secrets.E2E_GCP_WIF_PROVIDER }}
run: |
set -euo pipefail
if [[ -z "${WIF_PROVIDER}" ]]; then
echo "::error::E2E_GCP_WIF_PROVIDER is not configured — agents functional tests would skip and the release gate would pass without running a single test"
exit 1
fi
echo "::notice::E2E_GCP_WIF_PROVIDER is configured; the agents gate will run its tests"

- name: Resolve agents main
id: resolve
Expand All @@ -120,6 +151,35 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/check-agents-gate-pin.sh

recheck-tag:
# Second half of the tag-move guard. validate-agents clones fullsend by
# tag name — the called workflow only accepts `main` or a `v*` ref and
# uses `git clone --branch`, so it cannot be pinned to a SHA from this
# side. A tag moved during the ~45 minutes of functional tests would
# otherwise leave the gate validating one commit while GoReleaser
# publishes another. resolve-agents checks the tag before validation
# starts; this checks it again after, so publication requires the tag
# to have been stable across the whole gate.
#
# Its own job rather than a step inside `release` so that a tag moved
# mid-gate stays a *pre-publication* failure: nothing has shipped, and
# notify-release-blocked can say so.
needs: validate-agents
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
# Pinned to the commit, not the tag — see resolve-agents.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}

- name: Verify release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/verify-release-tag.sh

tag-agents:
# Sync the version tag to fullsend-ai/agents. Runs for all tags
# including pre-releases — agents' own release.yml handles
Expand Down Expand Up @@ -175,14 +235,18 @@ jobs:
-f sha="${AGENTS_SHA}"
echo "Created tag ${TAG} on fullsend-ai/agents at ${AGENTS_SHA}"

notify-agents-sync-failure:
needs: [release, validate-agents, resolve-agents, tag-agents]
notify-release-blocked:
# Pre-publication failure: nothing shipped. Before the gate moved ahead
# of GoReleaser this path did not exist — a validation failure still
# published the binary and Slack reported only the missing agents tag.
# Now the whole release stops here, so it needs its own signal;
# otherwise a blocked release is silent on Slack.
needs: [validate-agents, resolve-agents, recheck-tag]
if: >-
always()
&& needs.release.result == 'success'
&& (needs.validate-agents.result == 'failure'
|| needs.resolve-agents.result == 'failure'
|| needs.tag-agents.result == 'failure')
|| needs.recheck-tag.result == 'failure')
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
Expand All @@ -192,6 +256,37 @@ jobs:
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
if [ -z "$SLACK_WEBHOOK" ]; then
echo "::error::SLACK_WEBHOOK_URL secret is not configured"
exit 1
fi
PAYLOAD=$(jq -n --arg tag "${GITHUB_REF_NAME}" --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'{text: ":no_entry: Release `\($tag)` was blocked before publishing — tag verification or agents functional tests failed. No binary was published and no tags were moved. <\($url)|View run>"}')
curl -fsS -X POST -H "Content-Type: application/json" -d "$PAYLOAD" "$SLACK_WEBHOOK"

notify-agents-sync-failure:
# Post-publication failure: the binary shipped but the agents tag did
# not follow. validate-agents and resolve-agents are no longer checked
# here — `release` depends on both, so its success already implies they
# passed, and their failure is reported by notify-release-blocked.
needs: [release, tag-agents]
if: >-
always()
&& needs.release.result == 'success'
&& needs.tag-agents.result == 'failure'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Notify Slack
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
if [ -z "$SLACK_WEBHOOK" ]; then
echo "::error::SLACK_WEBHOOK_URL secret is not configured"
exit 1
fi
PAYLOAD=$(jq -n --arg tag "${GITHUB_REF_NAME}" --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'{text: ":warning: Agents tag sync failed for `\($tag)`. The fullsend release shipped but the agents tag was not created. <\($url)|View run>"}')
curl -fsS -X POST -H "Content-Type: application/json" -d "$PAYLOAD" "$SLACK_WEBHOOK"
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ script-test:
$(call run-timed,bash scripts/redact-behaviour-artifacts-test.sh)
$(call run-timed,bash .github/scripts/check-fix-eligibility-test.sh)
$(call run-timed,bash scripts/check-agents-gate-pin-test.sh)
$(call run-timed,bash scripts/verify-release-tag-test.sh)
$(call run-timed,bash internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh)
$(call run-timed,bash internal/scaffold/fullsend-repo/scripts/pre-fetch-prior-review-test.sh)
$(call run-timed,bash internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env-test.sh)
Expand Down
208 changes: 208 additions & 0 deletions scripts/verify-release-tag-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
# verify-release-tag-test.sh — Tests for verify-release-tag.sh
#
# Run from the repo root:
# bash scripts/verify-release-tag-test.sh

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="${SCRIPT_DIR}/verify-release-tag.sh"
FAILURES=0

TMPDIR="$(mktemp -d)"
trap 'rm -rf "${TMPDIR}"' EXIT

COMMIT_A="aaaa1111bbbb2222cccc3333dddd4444eeee5555"
COMMIT_B="5555eeee4444dddd3333cccc2222bbbb1111aaaa"
TAG_OBJ_1="1111111111111111111111111111111111111111"
TAG_OBJ_2="2222222222222222222222222222222222222222"

# build_mock creates a mock gh binary.
# $1 — JSON body returned for git/ref/tags/<tag>, or "fail" to error
# $2 — JSON body returned for git/tags/<TAG_OBJ_1> (optional)
# $3 — JSON body returned for git/tags/<TAG_OBJ_2> (optional)
build_mock() {
local ref_body="$1" tag_body_1="${2:-}" tag_body_2="${3:-}"
local mock_bin="${TMPDIR}/bin"

rm -rf "${mock_bin}"
mkdir -p "${mock_bin}"

cat > "${mock_bin}/gh" <<MOCKEOF
#!/usr/bin/env bash
if [[ "\$1" != "api" ]]; then
echo "mock gh: unexpected command: \$*" >&2
exit 1
fi

case "\$2" in
repos/fullsend-ai/fullsend/git/ref/tags/*)
if [[ '${ref_body}' == "fail" ]]; then
echo "gh: Not Found (HTTP 404)" >&2
exit 1
fi
echo '${ref_body}'
;;
repos/fullsend-ai/fullsend/git/tags/${TAG_OBJ_1})
if [[ -z '${tag_body_1}' ]]; then
echo "mock gh: no body configured for ${TAG_OBJ_1}" >&2
exit 1
fi
echo '${tag_body_1}'
;;
repos/fullsend-ai/fullsend/git/tags/${TAG_OBJ_2})
if [[ -z '${tag_body_2}' ]]; then
echo "mock gh: no body configured for ${TAG_OBJ_2}" >&2
exit 1
fi
echo '${tag_body_2}'
;;
*)
echo "mock gh: unexpected endpoint: \$2" >&2
exit 1
;;
esac
exit 0
MOCKEOF

chmod +x "${mock_bin}/gh"
echo "${mock_bin}"
}

# run_test runs the script under a mock gh and asserts exit code and output.
# $1 — test name
# $2 — expected exit code
# $3 — expected output substring ("" to skip)
# $4 — EXPECTED_SHA to pass in
# $5 — git/ref/tags body (or "fail")
# $6 — git/tags/<TAG_OBJ_1> body (optional)
# $7 — git/tags/<TAG_OBJ_2> body (optional)
run_test() {
local name="$1" expected_exit="$2" expected_output="$3" expected_sha="$4"
local ref_body="$5" tag_body_1="${6:-}" tag_body_2="${7:-}"

local mock_bin
mock_bin=$(build_mock "${ref_body}" "${tag_body_1}" "${tag_body_2}")

local actual_exit=0 output
output=$(
PATH="${mock_bin}:${PATH}" \
TAG="v9.9.9" \
EXPECTED_SHA="${expected_sha}" \
REPO="fullsend-ai/fullsend" \
GH_TOKEN="fake" \
bash "${SCRIPT}" 2>&1
) || actual_exit=$?

if [[ "${actual_exit}" -ne "${expected_exit}" ]]; then
echo "FAIL: ${name} — expected exit ${expected_exit}, got ${actual_exit}"
echo " output: ${output}"
FAILURES=$((FAILURES + 1))
return
fi

if [[ -n "${expected_output}" ]] && [[ "${output}" != *"${expected_output}"* ]]; then
echo "FAIL: ${name} — expected '${expected_output}' not found in output"
echo " output: ${output}"
FAILURES=$((FAILURES + 1))
return
fi

echo "PASS: ${name}"
}

echo "=== verify-release-tag tests ==="

# Lightweight tag pointing straight at the release commit.
run_test "lightweight tag matches" 0 "resolves to ${COMMIT_A}" "${COMMIT_A}" \
"{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_A}\"}}"

# Annotated tag: one peel to reach the commit.
run_test "annotated tag matches" 0 "resolves to ${COMMIT_A}" "${COMMIT_A}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_1}\"}}" \
"{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_A}\"}}"

# Nested annotated tag: two peels to reach the commit.
run_test "nested annotated tag matches" 0 "resolves to ${COMMIT_A}" "${COMMIT_A}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_1}\"}}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_2}\"}}" \
"{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_A}\"}}"

# Tag moved after the run started — must fail.
run_test "moved tag fails" 1 "expected ${COMMIT_A}" "${COMMIT_A}" \
"{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_B}\"}}"

# Annotated tag that was moved — the peel must not hide the mismatch.
run_test "moved annotated tag fails" 1 "expected ${COMMIT_A}" "${COMMIT_A}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_1}\"}}" \
"{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_B}\"}}"

# Tag ref cannot be read — must fail, never pass.
run_test "unreadable tag fails" 1 "Could not read tag" "${COMMIT_A}" "fail"

# Ref names something other than a commit.
run_test "non-commit tag target fails" 1 "does not name a commit" "${COMMIT_A}" \
"{\"object\":{\"type\":\"tree\",\"sha\":\"${COMMIT_A}\"}}"

# API returned something that is not a 40-hex SHA.
run_test "malformed sha fails" 1 "unexpected value" "${COMMIT_A}" \
'{"object":{"type":"commit","sha":"not-a-sha"}}'

# EXPECTED_SHA itself is malformed — fail before querying anything.
run_test "malformed expected sha fails" 1 "not a commit SHA" "HEAD" \
"{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_A}\"}}"

# Tag object cycle: peeling never reaches a commit — must fail, not hang.
run_test "peel depth exceeded fails" 1 "nested more than" "${COMMIT_A}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_1}\"}}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_2}\"}}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_1}\"}}"

# A tag object that cannot be dereferenced — must fail, never pass.
run_test "undereferenceable tag object fails" 1 "Could not dereference" "${COMMIT_A}" \
"{\"object\":{\"type\":\"tag\",\"sha\":\"${TAG_OBJ_1}\"}}"

# Missing required inputs.
missing_input_test() {
local mock_bin actual_exit=0 output
mock_bin=$(build_mock "{\"object\":{\"type\":\"commit\",\"sha\":\"${COMMIT_A}\"}}")
output=$(
PATH="${mock_bin}:${PATH}" \
TAG="" GITHUB_REF_NAME="" \
EXPECTED_SHA="${COMMIT_A}" \
REPO="fullsend-ai/fullsend" \
bash "${SCRIPT}" 2>&1
) || actual_exit=$?

if [[ "${actual_exit}" -ne 1 || "${output}" != *"requires TAG"* ]]; then
echo "FAIL: missing TAG — expected exit 1 with usage error, got ${actual_exit}: ${output}"
FAILURES=$((FAILURES + 1))
return
fi
echo "PASS: missing TAG"

actual_exit=0
output=$(
PATH="${mock_bin}:${PATH}" \
TAG="v9.9.9" \
EXPECTED_SHA="${COMMIT_A}" \
REPO="" GITHUB_REPOSITORY="" \
bash "${SCRIPT}" 2>&1
) || actual_exit=$?

if [[ "${actual_exit}" -ne 1 || "${output}" != *"requires TAG"* ]]; then
echo "FAIL: missing REPO — expected exit 1 with usage error, got ${actual_exit}: ${output}"
FAILURES=$((FAILURES + 1))
return
fi
echo "PASS: missing REPO"
}
missing_input_test

echo
if [[ "${FAILURES}" -gt 0 ]]; then
echo "${FAILURES} test(s) failed"
exit 1
fi
echo "All tests passed"
Loading
Loading