Skip to content
310 changes: 310 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
name: Automated FinOps Agent Release

on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g., 1.0.22)"
required: true
type: string
# Every irreversible action here is a `git push`, so a dry run passes
# --dry-run to each one: git still contacts the remote and validates auth
# and the ref update, but nothing lands. Steps that need a push to have
# actually landed are skipped with `if: !inputs.dry_run`. Not exercised:
# the image build, the chart publish, Chart.lock (which needs the new
# chart version to actually be published), and the kubecost PR.
dry_run:
description: "Dry run (validate and edit locally, push nothing)"
required: false
default: false
type: boolean

permissions:
contents: read

concurrency:
group: finops-agent-release
cancel-in-progress: false

env:
VERSION: ${{ inputs.version }}
TAG: v${{ inputs.version }}
BRANCH: finops-agent-v${{ inputs.version }}

# The only difference between a real release and a dry run.
PUSH_ARGS: ${{ inputs.dry_run && '--dry-run' || '' }}

# The cross-repo contract. The two workflow filenames name files in repos
# this one cannot see, so a rename there surfaces here only as a "did not
# start" timeout — keep them visible in one place.
BUILD_REPO: kubecost/integration-ci-cd
Comment thread
thomasvn marked this conversation as resolved.
BUILD_WORKFLOW: finops-agent-build-release.yaml
CHART_REPO: kubecost/finops-agent-chart
CHART_WORKFLOW: publish-chart.yaml
KUBECOST_REPO: kubecost/kubecost
KUBECOST_BRANCH: develop

# Applies to every commit and annotated tag this workflow creates.
GIT_AUTHOR_NAME: kc-actions-bot[bot]
GIT_AUTHOR_EMAIL: kc-actions-bot[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: kc-actions-bot[bot]
GIT_COMMITTER_EMAIL: kc-actions-bot[bot]@users.noreply.github.com

jobs:
tag-agent:
name: Validate and tag ibm-finops-agent
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Invalid version format. Expected: X.Y.Z (e.g., 1.0.22)"
exit 1
fi
echo "✅ Version format valid: $VERSION"

- name: Generate app token to tag ibm-finops-agent and watch the build
id: generate-app-token
uses: actions/create-github-app-token@v3.2.0
with:
client-id: ${{ vars.KC_ACTIONS_BOT_APP_CLIENT_ID }}
private-key: ${{ secrets.KC_ACTIONS_BOT_APP_PRIVATE_KEY }}
repositories: |
ibm-finops-agent
integration-ci-cd

- name: Checkout ibm-finops-agent
uses: actions/checkout@v6
with:
ref: develop
token: ${{ steps.generate-app-token.outputs.token }}

- name: Check tag doesn't exist
run: |
if git ls-remote --exit-code --tags origin "$TAG" >/dev/null 2>&1; then
echo "❌ Tag $TAG already exists"
exit 1
fi
echo "✅ Tag $TAG is available"

- name: Create and push tag
id: push-tag
run: |
# Recorded before the push, so the build poll below can tell our run
# apart from an earlier release's.
echo "since=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
git tag -a "$TAG" -m "Release $TAG"
git push $PUSH_ARGS origin "$TAG"

# The tag push triggers build-release.yaml, which dispatches to
# $BUILD_REPO. Nothing in the resulting run names our tag — a
# repository_dispatch run reports the default branch — so --created is
# the only thing distinguishing it from the last release's run. Without
# it, --limit 1 returns that older run and `gh run watch` exits
# immediately with its stale success.
- name: Wait for image build
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ steps.generate-app-token.outputs.token }}
SINCE: ${{ steps.push-tag.outputs.since }}
timeout-minutes: 30
run: |
for i in {1..24}; do
RUN_ID=$(gh run list --repo "$BUILD_REPO" --workflow "$BUILD_WORKFLOW" \
--event repository_dispatch --created ">=$SINCE" --limit 1 \
--json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then break; fi
echo "⏳ Waiting for build to start (attempt $i/24)..."
sleep 5
done
: "${RUN_ID:?build workflow did not start within 2 minutes}"

gh run watch "$RUN_ID" --repo "$BUILD_REPO" --exit-status

update-chart:
name: Update finops-agent-chart
needs: tag-agent
runs-on: ubuntu-latest
steps:
- name: Generate app token to update finops-agent-chart
id: generate-app-token
uses: actions/create-github-app-token@v3.2.0
with:
client-id: ${{ vars.KC_ACTIONS_BOT_APP_CLIENT_ID }}
private-key: ${{ secrets.KC_ACTIONS_BOT_APP_PRIVATE_KEY }}
repositories: finops-agent-chart

- name: Checkout finops-agent-chart
uses: actions/checkout@v6
with:
repository: ${{ env.CHART_REPO }}
ref: main
token: ${{ steps.generate-app-token.outputs.token }}

- name: Bump image tag and chart version
run: |
sed -i -E "/^image:/,/^[^ ]/ s|^ tag: .*| tag: $TAG|" charts/finops-agent/values.yaml
sed -i -E "s|^version: .*|version: $VERSION|;
s|^appVersion: .*|appVersion: $TAG|" charts/finops-agent/Chart.yaml

git --no-pager diff

verify() {
actual=$(yq "$2" "$1")
[ "$actual" = "$3" ] || { echo "❌ $1: $2 is '$actual', expected '$3'"; exit 1; }
}
verify charts/finops-agent/values.yaml .image.tag "$TAG"
verify charts/finops-agent/Chart.yaml .version "$VERSION"
verify charts/finops-agent/Chart.yaml .appVersion "$TAG"

- name: Commit, tag, and push
run: |
git add charts/finops-agent/values.yaml charts/finops-agent/Chart.yaml
git commit -m "Release $TAG"
git tag -a "$TAG" -m "Release $TAG"
git push $PUSH_ARGS origin main "$TAG"

# --branch matches the tag ref, so this correlates to the tag just
# pushed rather than to whatever happened to run most recently.
- name: Wait for chart publish
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ steps.generate-app-token.outputs.token }}
timeout-minutes: 20
run: |
for i in {1..24}; do
RUN_ID=$(gh run list --repo "$CHART_REPO" --workflow "$CHART_WORKFLOW" \
--event push --branch "$TAG" --limit 1 \
--json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then break; fi
echo "⏳ Waiting for chart publish to start (attempt $i/24)..."
sleep 5
done
: "${RUN_ID:?chart publish workflow did not start within 2 minutes}"

gh run watch "$RUN_ID" --repo "$CHART_REPO" --exit-status

update-kubecost:
name: Update kubecost chart dependency
needs: update-chart
runs-on: ubuntu-latest
steps:
- name: Generate app token to update kubecost
id: generate-app-token
uses: actions/create-github-app-token@v3.2.0
with:
client-id: ${{ vars.KC_ACTIONS_BOT_APP_CLIENT_ID }}
private-key: ${{ secrets.KC_ACTIONS_BOT_APP_PRIVATE_KEY }}
repositories: kubecost

- name: Checkout kubecost
uses: actions/checkout@v6
with:
repository: ${{ env.KUBECOST_REPO }}
ref: ${{ env.KUBECOST_BRANCH }}
token: ${{ steps.generate-app-token.outputs.token }}

- name: Install Helm
uses: azure/setup-helm@v3
with:
version: v3.12.0

- name: Bump finops-agent dependency
run: |
sed -i -E "/^ - name: finops-agent$/,/^ - name:/ s|^ version: .*| version: \"$TAG\"|" kubecost/Chart.yaml

git --no-pager diff

actual=$(yq '.dependencies[] | select(.name == "finops-agent") | .version' kubecost/Chart.yaml)
Comment thread
thomasvn marked this conversation as resolved.
[ "$actual" = "$TAG" ] || { echo "❌ dependency is '$actual', expected '$TAG'"; exit 1; }

# Needs the new chart version to actually be published, so it can only
# run for real. Chart.lock is left untouched in a dry run.
- name: Update Chart.lock
if: ${{ !inputs.dry_run }}
timeout-minutes: 10
run: |
helm repo add finops-agent https://kubecost.github.io/finops-agent-chart/ || true
for i in {1..60}; do
helm repo update finops-agent
PUBLISHED=$(helm search repo finops-agent --version "$VERSION" \
--output json | jq -r '.[0].version // empty')
if [ -n "$PUBLISHED" ]; then break; fi
echo "⏳ Waiting for chart $VERSION to reach the published index (attempt $i/60)..."
sleep 5
done
: "${PUBLISHED:?chart $VERSION did not reach the published index within 5 minutes}"

helm dependency update ./kubecost

- name: Commit and push branch
run: |
git checkout -b "$BRANCH"
git add kubecost/Chart.yaml kubecost/Chart.lock
git commit -m "Update finops-agent to $TAG"
git push $PUSH_ARGS origin "$BRANCH"

- name: Open PR against kubecost
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ steps.generate-app-token.outputs.token }}
run: |
gh pr create --repo "$KUBECOST_REPO" \
--base "$KUBECOST_BRANCH" --head "$BRANCH" \
--label dependencies \
--title "Update finops-agent to $TAG" \
--body "Automated by https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"

summary:
name: Release Summary
needs: [tag-agent, update-chart, update-kubecost]
runs-on: ubuntu-latest
if: always()
steps:
- name: Report status
env:
TAG_RESULT: ${{ needs.tag-agent.result }}
CHART_RESULT: ${{ needs.update-chart.result }}
KUBECOST_RESULT: ${{ needs.update-kubecost.result }}
run: |
mark() {
case "$1" in
success) echo "✅" ;;
skipped) echo "⏭️" ;;
*) echo "❌" ;;
esac
}

if [ "${{ inputs.dry_run }}" = "true" ]; then
TITLE="🔍 Dry Run — FinOps Agent Release $TAG (nothing was pushed)"
else
TITLE="FinOps Agent Release $TAG"
fi

cat >> "$GITHUB_STEP_SUMMARY" <<EOF
# $TITLE

- $(mark "$TAG_RESULT") ibm-finops-agent tagged and image built
- $(mark "$CHART_RESULT") finops-agent-chart updated and published
- $(mark "$KUBECOST_RESULT") kubecost chart dependency PR opened — merge it to finish the release

[agent tag](https://github.com/${{ github.repository }}/releases/tag/$TAG) &middot;
[chart release](https://github.com/$CHART_REPO/releases/tag/$TAG) &middot;
[kubecost PR](https://github.com/$KUBECOST_REPO/pulls?q=head%3A$BRANCH)
EOF

# A dry run pushes no tags, so there is never anything to clean up.
- name: Add cleanup instructions
if: ${{ !inputs.dry_run && contains(needs.*.result, 'failure') }}
run: |
cat >> "$GITHUB_STEP_SUMMARY" <<EOF

## Manual cleanup

The release stopped partway through. Delete whichever tags were pushed:

\`\`\`bash
gh api -X DELETE repos/${{ github.repository }}/git/refs/tags/$TAG
gh api -X DELETE repos/$CHART_REPO/git/refs/tags/$TAG
\`\`\`
EOF
Loading