Nightly release gate #118
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |