Skip to content

Nightly release gate #118

Nightly release gate

Nightly release gate #118

# Maintainer CI: not part of cascade's generated output.
#
# Nightly release gate. cascade's orchestrate workflow is dispatch-only
# (release_trigger: dispatch in .github/manifest.yaml), so a trunk merge no
# longer cuts a release candidate on its own. This workflow is the single gate
# that decides, once per night, whether main has accumulated release-worthy
# changes since the last published release and, if so, dispatches orchestrate
# to cut the candidate. The existing orchestrate -> Release -> Fleet E2E ->
# Auto-promote chain runs unchanged from there.
#
# Two manual inputs:
# force bypass the change-since-last-release skip (release even if main is
# unchanged).
# dry_run rehearse the whole path (candidate cut, Release, full fleet,
# artifact handoff, auto-promote wiring) WITHOUT publishing, by
# cutting a vX.Y.Z-dryrun.N prerelease tag instead of an rc. The
# fleet accepts -dryrun. tags; auto-promote promotes only -rc. tags,
# so a dry run can never publish (see auto-promote.yaml's gate).
name: Nightly release gate
on:
schedule:
# 07:00 UTC daily, off-peak, after late-day merges settle. A missed night
# just defers: the diff is always measured against the last release, not the
# last night, so accumulated changes still release on the next run.
- cron: '0 7 * * *'
workflow_dispatch:
inputs:
force:
description: 'Bypass the change-since-last-release skip (run even if main is unchanged).'
type: boolean
default: false
dry_run:
description: 'Rehearse cut + Release + full fleet WITHOUT publishing the final release.'
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly-release
cancel-in-progress: false
jobs:
decide:
name: Decide whether to release
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
run: ${{ steps.decide.outputs.run }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history + tags so the last-release ref and the diff resolve.
fetch-depth: 0
- name: Decide whether main changed since the last release
id: decide
env:
# Empty on the schedule path; 'true' only when a maintainer ticks the
# box on a manual run.
FORCE: ${{ github.event.inputs.force }}
run: |
set -euo pipefail
# force short-circuits the change-skip entirely.
if [ "${FORCE:-false}" = "true" ]; then
echo "::notice::force=true; releasing even if main is unchanged."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch --tags --force --quiet origin
# Last-release ref: the latest FINAL release tag only. The exact
# vX.Y.Z shape excludes -rc. and -dryrun. prerelease tags, so a
# candidate or a leftover dry-run tag can never become the diff base.
LAST_RELEASE=$(git tag -l 'v*' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V \
| tail -n1 || true)
# Fail open: with no final release yet (bootstrap), or an unresolvable
# ref, proceed rather than silently skip a real release.
if [ -z "$LAST_RELEASE" ]; then
echo "::notice::No final vX.Y.Z release found; failing open (run=true)."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git rev-parse -q --verify "refs/tags/${LAST_RELEASE}" >/dev/null; then
echo "::warning::Last-release ref ${LAST_RELEASE} is unresolvable; failing open (run=true)."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::notice::Diffing ${LAST_RELEASE}..origin/main"
CHANGED=$(git diff --name-only "${LAST_RELEASE}..origin/main" || true)
if [ -z "$CHANGED" ]; then
echo "::notice::No file changes since ${LAST_RELEASE}; skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Walk the changed files. A file is release-worthy when it is in the
# include set AND not in the exclude set. The manifest is special: a
# pure-state '[skip ci]' write only touches the ci.state subtree, so it
# counts only when its non-state subtree actually changed.
relevant=false
manifest_nonstate_changed() {
local old new
old=$(git show "${LAST_RELEASE}:.github/manifest.yaml" 2>/dev/null \
| yq 'del(.ci.state)' - 2>/dev/null || echo "__missing_old__")
new=$(git show "origin/main:.github/manifest.yaml" 2>/dev/null \
| yq 'del(.ci.state)' - 2>/dev/null || echo "__missing_new__")
[ "$old" != "$new" ]
}
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
# Exclude: never a release on their own.
docs/*|*.md|CONTRIBUTING*|LICENSE*|*/plans/*)
continue
;;
# Manifest: only its non-state subtree is release-worthy.
.github/manifest.yaml)
if manifest_nonstate_changed; then
echo "::notice::Release-worthy change: ${f} (non-state subtree)"
relevant=true
fi
;;
# Include: code and shipped action surface.
cmd/*|internal/*|go.mod|go.sum|.github/actions/*)
echo "::notice::Release-worthy change: ${f}"
relevant=true
;;
*)
# Anything else is not release-worthy on its own.
;;
esac
done <<< "$CHANGED"
if [ "$relevant" = "true" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::Only non-release-worthy paths changed since ${LAST_RELEASE}; skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Summarize decision
if: always()
env:
RUN: ${{ steps.decide.outputs.run }}
FORCE: ${{ github.event.inputs.force }}
DRY_RUN: ${{ github.event.inputs.dry_run }}
run: |
{
echo "## Nightly release decision"
echo ""
echo "| input | value |"
echo "| --- | --- |"
echo "| run | ${RUN:-<unset>} |"
echo "| force | ${FORCE:-false} |"
echo "| dry_run | ${DRY_RUN:-false} |"
} >> "$GITHUB_STEP_SUMMARY"
dispatch:
name: Dispatch the release candidate
needs: decide
if: needs.decide.outputs.run == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Real run: dispatch the (dispatch-only) orchestrate workflow with the
# trigger-capable PAT. GITHUB_TOKEN would dispatch but its downstream
# rc-tag push would not fire Release, killing the chain silently.
# orchestrate's finalize cuts the rc and the existing chain proceeds.
- name: Dispatch orchestrate (real run)
if: github.event.inputs.dry_run != 'true'
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
run: |
set -euo pipefail
gh workflow run orchestrate.yaml \
--repo "${GITHUB_REPOSITORY}" \
--field dry_run=false
echo "::notice::Dispatched orchestrate (real release candidate)."
# Dry run: instead of dispatching orchestrate (whose finalize would route
# the tag through cascade's rc-only version parser and reject a dryrun
# label), the gate cuts the candidate itself with a -dryrun. identity.
# The tag is the only carrier that survives the workflow_run boundaries:
# Release builds it, the (broadened) fleet validates it, and auto-promote's
# unchanged -rc.-only gate rejects it, so nothing publishes by construction.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
if: github.event.inputs.dry_run == 'true'
with:
fetch-depth: 0
# Check out with the PAT so the tag push below triggers Release.
token: ${{ secrets.CASCADE_STATE_TOKEN }}
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
if: github.event.inputs.dry_run == 'true'
with:
go-version-file: go.mod
- name: Compute candidate version (dry run)
id: version
if: github.event.inputs.dry_run == 'true'
run: |
set -euo pipefail
go build -o /tmp/cascade ./cmd/cascade
# orchestrate setup computes the next candidate against current HEAD
# and writes `version=vX.Y.Z-rc.N` to $GITHUB_OUTPUT (read-only; only
# finalize writes state).
/tmp/cascade orchestrate setup \
--config .github/manifest.yaml \
--gha-output
- name: Cut and push dry-run tag (dry run)
if: github.event.inputs.dry_run == 'true'
env:
# The rc-shaped candidate (vX.Y.Z-rc.N) computed above.
CANDIDATE: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
if [ -z "${CANDIDATE:-}" ]; then
echo "::error::No candidate version computed; cannot cut a dry-run tag."
exit 1
fi
# Strip the -rc.N suffix to the vX.Y.Z core, then label the rehearsal
# tag with -dryrun.<run_number> for a unique, monotonic, well-formed
# prerelease that auto-promote will never publish.
BASE="${CANDIDATE%-rc.*}"
DRYRUN_TAG="${BASE}-dryrun.${GITHUB_RUN_NUMBER}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git rev-parse -q --verify "refs/tags/${DRYRUN_TAG}" >/dev/null; then
echo "::notice::Tag ${DRYRUN_TAG} already exists; reusing."
else
git tag -a "${DRYRUN_TAG}" "origin/main" \
-m "Dry-run release rehearsal for ${BASE} (no publish)"
git push origin "refs/tags/${DRYRUN_TAG}"
fi
echo "::notice::Pushed dry-run tag ${DRYRUN_TAG}; Release will build it and the full fleet will validate it. Auto-promote's -rc.-only gate keeps it unpublished. The -dryrun.* tag is excluded from the change-detection base; the sweep-dryrun-tags job prunes older ones once their rehearsal chain settles."
{
echo "## Dry-run candidate"
echo ""
echo "Cut \`${DRYRUN_TAG}\` (rehearses \`${CANDIDATE}\`). This will run Release + the full fleet but cannot publish."
} >> "$GITHUB_STEP_SUMMARY"
sweep-dryrun-tags:
name: Prune accumulated dry-run tags
# Release hygiene, independent of the release decision. A dry run cuts a
# vX.Y.Z-dryrun.N tag (and GoReleaser cuts a matching prerelease) that is
# never published. That tag is load-bearing only while its own rehearsal
# chain (Release -> Fleet E2E -> Auto-promote) is in flight, so a same-job
# delete would tear the chain down before it runs. This prunes retention-
# based instead: it keeps the newest few dry-run tags (an in-flight
# rehearsal is always among the newest) and deletes the older accumulated
# ones, so they cannot pile up and pollute version-calc.
#
# Always runs, including on a dry-run dispatch: retention only ever deletes
# tags OLDER than the newest few, so the tag a concurrent dispatch job is
# cutting (always the newest by monotonic run number) can never be among
# them even if this job races ahead of that push. Skipping the whole job on
# a dry-run dispatch (the previous behavior) left pruning off for as long as
# a maintainer kept rehearsing back to back, which is exactly when dry-run
# tags accumulate fastest.
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Prune old dry-run tags and their prereleases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Keep this many newest dry-run tags so an in-flight rehearsal (always
# among the newest) and a couple more for debugging are never pruned.
RETENTION: '3'
run: |
set -euo pipefail
# Match ONLY well-formed dry-run prerelease tags: vX.Y.Z-dryrun.N,
# anchored start to end. A final release (vX.Y.Z) carries no suffix and
# a candidate (vX.Y.Z-rc.N) carries -rc., so neither can ever match;
# only pure -dryrun.N tags reach the delete path.
dryrun_re='^v[0-9]+\.[0-9]+\.[0-9]+-dryrun\.[0-9]+$'
git fetch --tags --force --prune --prune-tags --quiet origin
# A dry-run release can outlive its tag: if the tag is ever removed
# out-of-band (e.g. a manual `git push --delete` that does not also
# delete the release object), the release is left dangling with no
# ref to find it by. A tag-only scan can never see it again, so it
# accumulates invisibly forever. Reconcile those first: with no live
# tag there is no in-flight rehearsal chain to protect, so retention
# does not apply and each one is deleted unconditionally.
mapfile -t releases < <(
gh release list -L 200 --json tagName --jq '.[].tagName' \
| grep -E "$dryrun_re" || true
)
for tag in "${releases[@]}"; do
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null 2>&1; then
continue # has a live tag; handled by the retention pass below
fi
gh release delete "$tag" --yes
echo "::notice::Deleted orphaned dry-run release ${tag} (tag already gone)."
done
mapfile -t tags < <(git tag -l | grep -E "$dryrun_re" | sort -V || true)
count=${#tags[@]}
if [ "$count" -le "$RETENTION" ]; then
echo "::notice::${count} dry-run tag(s) present; at or under retention (${RETENTION}); nothing to prune."
exit 0
fi
prune_count=$((count - RETENTION))
echo "::notice::${count} dry-run tag(s); keeping newest ${RETENTION}, pruning ${prune_count} oldest."
pruned=0
# sort -V is ascending, so the oldest tags are first; slice off exactly
# the ones beyond retention.
for tag in "${tags[@]:0:prune_count}"; do
# Defense in depth: re-assert the shape before any delete, even though
# the list is already filtered, so no rc or release tag can slip in.
if ! printf '%s' "$tag" | grep -Eq "$dryrun_re"; then
echo "::warning::Refusing to delete non-dry-run ref ${tag}."
continue
fi
if gh release view "$tag" >/dev/null 2>&1; then
# Deletes the prerelease and its underlying tag in one call.
gh release delete "$tag" --cleanup-tag --yes
echo "::notice::Deleted prerelease and tag ${tag}."
else
git push origin --delete "refs/tags/${tag}"
echo "::notice::Deleted tag ${tag} (no release found)."
fi
pruned=$((pruned + 1))
done
{
echo "## Dry-run tag sweep"
echo ""
echo "Pruned ${pruned} dry-run tag(s); kept the newest ${RETENTION}."
} >> "$GITHUB_STEP_SUMMARY"