Skip to content
277 changes: 277 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
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 only observe a push's
# side effects are skipped with `if: !inputs.dry_run`. Not exercised: the
# image build, the chart publish, and Chart.lock (which needs the new
# chart version to actually be published).
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:
TAG: 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 ! [[ "${{ inputs.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: ${{ inputs.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
run: |
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. Poll for the run that appears there, then watch it.
- name: Wait for image build
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ steps.generate-app-token.outputs.token }}
timeout-minutes: 30
run: |
for i in {1..24}; do
RUN_ID=$(gh run list --repo "$BUILD_REPO" --workflow "$BUILD_WORKFLOW" \
--event repository_dispatch --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: ${{ inputs.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 "${{ inputs.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 }}
run: |
helm repo add finops-agent https://kubecost.github.io/finops-agent-chart/ || true
helm repo update finops-agent
helm dependency update ./kubecost

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

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 updated

[agent tag](https://github.com/${{ github.repository }}/releases/tag/$TAG) &middot;
[chart release](https://github.com/$CHART_REPO/releases/tag/$TAG) &middot;
[kubecost commits](https://github.com/$KUBECOST_REPO/commits/$KUBECOST_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