CLI Release from releases/v0.21 #104
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
| name: CLI Release | |
| run-name: CLI Release from ${{ github.event.inputs.branch }}${{ github.event.inputs.dry_run == 'true' && ' (dry-run)' || '' }} | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| branch: | |
| description: "Branch to release from, must match regex releases/v0.[0-9]+" | |
| required: true | |
| type: string | |
| dry_run: | |
| description: "Dry-run RC phase without pushing tags or creating releases." | |
| required: false | |
| default: true | |
| type: boolean | |
| env: | |
| REGISTRY: ghcr.io | |
| COMPONENT_PATH: cli | |
| concurrency: | |
| cancel-in-progress: true | |
| group: cli-release-${{ github.event.inputs.branch }} | |
| jobs: | |
| # ============================================================ | |
| # PHASE 1: RELEASE CANDIDATE | |
| # ============================================================ | |
| # -------------------------------------------------------- | |
| # 1. Prepare: Compute RC version and generate changelog | |
| # -------------------------------------------------------- | |
| prepare: | |
| name: Prepare Release Metadata | |
| uses: ./.github/workflows/release-candidate-version.yml | |
| with: | |
| branch: ${{ github.event.inputs.branch }} | |
| component_path: cli | |
| secrets: inherit | |
| # -------------------------------------------------------- | |
| # 2. Tag RC: Create and push RC tag (skipped on dry-run) | |
| # -------------------------------------------------------- | |
| tag_rc: | |
| name: Create and Push RC Tag | |
| runs-on: ubuntu-latest | |
| needs: [prepare] | |
| if: ${{ github.event.inputs.dry_run == 'false' }} | |
| permissions: | |
| contents: write | |
| outputs: | |
| pushed: ${{ steps.tag.outputs.pushed }} | |
| steps: | |
| - name: Generate App Token | |
| id: get_token | |
| uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 | |
| with: | |
| app-id: ${{ secrets.OCMBOT_APP_ID }} | |
| private-key: ${{ secrets.OCMBOT_PRIV_KEY }} | |
| - name: Checkout Repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| sparse-checkout: ${{ env.COMPONENT_PATH }} | |
| ref: ${{ github.event.inputs.branch }} | |
| token: ${{ steps.get_token.outputs.token }} | |
| - name: Setup git config | |
| run: | | |
| git config --global user.name "${{ github.actor }}" | |
| git config --global user.email "${{ github.actor }}@users.noreply.github.com" | |
| - name: Create ${{ needs.prepare.outputs.new_tag }} | |
| id: tag | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| TAG: ${{ needs.prepare.outputs.new_tag }} | |
| CHANGELOG_B64: ${{ needs.prepare.outputs.changelog_b64 }} | |
| with: | |
| github-token: ${{ steps.get_token.outputs.token }} | |
| script: | | |
| const { execSync } = require("child_process"); | |
| const tag = process.env.TAG; | |
| const msg = Buffer.from(process.env.CHANGELOG_B64, "base64").toString("utf8"); | |
| try { execSync(`git rev-parse "refs/tags/${tag}"`); core.info(`Tag ${tag} exists`); core.setOutput("pushed","false"); return; } catch {} | |
| require("fs").writeFileSync(".tagmsg", msg); | |
| execSync(`git tag -a "${tag}" -F .tagmsg`); | |
| execSync(`git push origin "refs/tags/${tag}"`); | |
| core.setOutput("pushed","true"); | |
| core.info(`✅ Created RC tag ${tag}`); | |
| # -------------------------------------------------------- | |
| # 3. Build CLI | |
| # -------------------------------------------------------- | |
| build: | |
| name: Build CLI for ${{ needs.prepare.outputs.new_tag }} | |
| if: ${{ github.event.inputs.dry_run == 'false' && needs.tag_rc.outputs.pushed == 'true' }} | |
| needs: [prepare, tag_rc] | |
| uses: ./.github/workflows/cli.yml | |
| secrets: inherit | |
| with: | |
| ref: ${{ needs.prepare.outputs.new_tag }} | |
| # -------------------------------------------------------- | |
| # 4. Release RC: Create GitHub pre-release | |
| # -------------------------------------------------------- | |
| release_rc: | |
| name: Create RC Pre-Release | |
| if: ${{ github.event.inputs.dry_run == 'false' && needs.tag_rc.outputs.pushed == 'true' }} | |
| needs: [prepare, tag_rc, build] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Decode changelog to file | |
| env: | |
| CHANGELOG_B64: ${{ needs.prepare.outputs.changelog_b64 }} | |
| run: | | |
| if [ -z "$CHANGELOG_B64" ]; then | |
| echo "::error::changelog_b64 is empty - release-candidate-version workflow failed" | |
| exit 1 | |
| fi | |
| echo "$CHANGELOG_B64" | base64 --decode > "${{ runner.temp }}/CHANGELOG.md" | |
| - name: Download CLI artifacts | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| repository: ${{ github.repository }} | |
| name: ${{ needs.build.outputs.artifact_name }} | |
| path: ${{ runner.temp }}/rc-build-assets | |
| - name: Create RC Release | |
| uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 | |
| with: | |
| name: CLI ${{ needs.prepare.outputs.new_version }} | |
| tag_name: ${{ needs.prepare.outputs.new_tag }} | |
| body_path: ${{ runner.temp }}/CHANGELOG.md | |
| fail_on_unmatched_files: true | |
| prerelease: true | |
| files: | | |
| ${{ runner.temp }}/rc-build-assets/bin/ocm-* | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Summarize RC release | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| RC_VERSION: ${{ needs.prepare.outputs.new_version }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const release = await github.rest.repos.getReleaseByTag({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| tag: process.env.RC_TAG, | |
| }); | |
| const releaseUrl = release.data.html_url; | |
| const assetsCount = Array.isArray(release.data.assets) ? release.data.assets.length : 0; | |
| await core.summary | |
| .addHeading('RC Release Published') | |
| .addTable([ | |
| [{ data: 'Field', header: true }, { data: 'Value', header: true }], | |
| ['RC Tag', process.env.RC_TAG], | |
| ['RC Version', process.env.RC_VERSION], | |
| ['Uploaded Assets', String(assetsCount)], | |
| ]) | |
| .addEOL() | |
| .addLink(releaseUrl, releaseUrl) | |
| .addEOL() | |
| .write(); | |
| # ============================================================ | |
| # PHASE 2: FINAL RELEASE (after environment gate) | |
| # ============================================================ | |
| # Environment Gate: | |
| # The "cli/release" environment must be configured in GitHub Settings → Environments: | |
| # - Required reviewers: At least 1 reviewer must approve | |
| # - Wait timer: 20160 minutes (14 days) | |
| # | |
| # This gate blocks all Phase 2 jobs until approved. | |
| # -------------------------------------------------------- | |
| # 5. Verify Attestations: Ensure RC artifacts are attestable | |
| # -------------------------------------------------------- | |
| verify_attestations: | |
| name: Verify RC Attestations | |
| needs: [prepare, build, release_rc] | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: cli/release | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - name: Download RC binaries | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| run: | | |
| gh release download "$RC_TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "ocm-*" \ | |
| --dir "${{ runner.temp }}/binaries" | |
| - name: Verify binary attestations | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| for file in ${{ runner.temp }}/binaries/ocm-*; do | |
| echo "Verifying $(basename "$file")..." | |
| gh attestation verify "$file" --repo "${{ github.repository }}" | |
| done | |
| echo "✅ All binary attestations verified" | |
| - name: Verify OCI image attestation | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| IMAGE_DIGEST: ${{ needs.build.outputs.image_digest }} | |
| TARGET_REPO: ${{ env.REGISTRY }}/${{ github.repository_owner }}/cli | |
| run: | | |
| echo "Verifying OCI image attestation for ${TARGET_REPO}@${IMAGE_DIGEST}..." | |
| gh attestation verify "oci://${TARGET_REPO}@${IMAGE_DIGEST}" --repo "${{ github.repository }}" | |
| echo "✅ OCI image attestation verified" | |
| # -------------------------------------------------------- | |
| # 6. Promote Final: Create final tag and promote OCI image | |
| # -------------------------------------------------------- | |
| promote_final: | |
| name: Promote to Final Release | |
| needs: [prepare, build, verify_attestations] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| packages: write | |
| steps: | |
| - name: Validate release preconditions | |
| env: | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| PROMOTION_TAG: ${{ needs.prepare.outputs.promotion_tag }} | |
| run: | | |
| if [ -z "$RC_TAG" ]; then | |
| echo "::error::Missing RC tag from prepare step" | |
| exit 1 | |
| fi | |
| if [ -z "$PROMOTION_TAG" ]; then | |
| echo "::error::Missing promotion tag" | |
| exit 1 | |
| fi | |
| echo "✅ RC: $RC_TAG → Final: $PROMOTION_TAG" | |
| - name: Generate App Token | |
| id: get_token | |
| uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 | |
| with: | |
| app-id: ${{ secrets.OCMBOT_APP_ID }} | |
| private-key: ${{ secrets.OCMBOT_PRIV_KEY }} | |
| - name: Checkout Repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| sparse-checkout: ${{ env.COMPONENT_PATH }} | |
| fetch-depth: 0 | |
| ref: ${{ github.event.inputs.branch }} | |
| token: ${{ steps.get_token.outputs.token }} | |
| - name: Setup git config | |
| run: | | |
| git config --global user.name "${{ github.actor }}" | |
| git config --global user.email "${{ github.actor }}@users.noreply.github.com" | |
| - name: Create final tag from RC commit | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| FINAL_TAG: ${{ needs.prepare.outputs.promotion_tag }} | |
| with: | |
| github-token: ${{ steps.get_token.outputs.token }} | |
| script: | | |
| const { execSync } = require("child_process"); | |
| const rcTag = process.env.RC_TAG; | |
| const finalTag = process.env.FINAL_TAG; | |
| if (!rcTag || !finalTag) { | |
| core.setFailed("Missing RC_TAG or FINAL_TAG"); | |
| return; | |
| } | |
| try { | |
| execSync(`git rev-parse "refs/tags/${finalTag}"`, { stdio: "pipe" }); | |
| core.setFailed(`Final tag ${finalTag} already exists. Refusing to overwrite immutable tag.`); | |
| return; | |
| } catch {} | |
| const rcSha = execSync(`git rev-parse "refs/tags/${rcTag}^{commit}"`, { stdio: "pipe" }).toString().trim(); | |
| if (!rcSha) { | |
| core.setFailed(`Could not resolve commit for RC tag ${rcTag}`); | |
| return; | |
| } | |
| execSync(`git tag -a "${finalTag}" "${rcSha}" -m "Promote ${rcTag} to ${finalTag}"`); | |
| execSync(`git push origin "refs/tags/${finalTag}"`); | |
| core.info(`✅ Created final tag ${finalTag} from ${rcTag} at ${rcSha}`); | |
| - name: Setup ORAS | |
| uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Promote OCI image tags | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| RC_VERSION: ${{ needs.prepare.outputs.new_version }} | |
| FINAL_VERSION: ${{ needs.prepare.outputs.promotion_version }} | |
| TARGET_REPO: ${{ env.REGISTRY }}/${{ github.repository_owner }}/cli | |
| with: | |
| script: | | |
| const { execSync } = require('child_process'); | |
| const { RC_VERSION: rc, FINAL_VERSION: final, TARGET_REPO: repo } = process.env; | |
| if (!rc || !final) { core.setFailed('Missing RC_VERSION or FINAL_VERSION'); return; } | |
| // Get highest existing final version from GHCR | |
| let highest = ''; | |
| try { | |
| const { data } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({ | |
| package_type: 'container', package_name: 'cli', org: context.repo.owner | |
| }); | |
| highest = data.flatMap(v => v.metadata?.container?.tags || []) | |
| .filter(t => /^v\d+\.\d+\.\d+$/.test(t)) | |
| .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) | |
| .pop() || ''; | |
| } catch (e) { core.warning(`Could not fetch existing tags: ${e.message}`); } | |
| // Set :latest only if this version is >= the current highest | |
| const setLatest = !highest || final.localeCompare(highest, undefined, { numeric: true }) >= 0; | |
| const tags = setLatest ? `"${final}" "latest"` : `"${final}"`; | |
| execSync(`oras tag "${repo}:${rc}" ${tags}`, { stdio: 'inherit' }); | |
| core.info(setLatest ? `✅ Tagged :${final} and :latest` : `⚠️ Tagged :${final} (${highest} is higher)`); | |
| await core.summary.addHeading('OCI Image Promotion').addTable([ | |
| [{data: 'Field', header: true}, {data: 'Value', header: true}], | |
| ['Source', `${repo}:${rc}`], | |
| ['Final', `${repo}:${final}`], | |
| ['Latest', setLatest ? 'Yes' : `No (${highest} > ${final})`], | |
| ]).write(); | |
| # -------------------------------------------------------- | |
| # 7. Release Final: Create GitHub final release | |
| # -------------------------------------------------------- | |
| release_final: | |
| name: Create Final Release | |
| needs: [prepare, promote_final] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| # Get release notes from RC release body (no separate asset needed) | |
| - name: Get release notes from RC | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| run: | | |
| gh release view "$RC_TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --json body \ | |
| --jq '.body' > "${{ runner.temp }}/CHANGELOG.md" | |
| # Download binaries from RC release assets (same binaries that were tested) | |
| - name: Download RC release binaries | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| run: | | |
| gh release download "$RC_TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "ocm-*" \ | |
| --dir "${{ runner.temp }}/binaries" | |
| - name: Publish final release | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| FINAL_TAG: ${{ needs.prepare.outputs.promotion_tag }} | |
| FINAL_VERSION: ${{ needs.prepare.outputs.promotion_version }} | |
| TARGET_REPO: ${{ env.REGISTRY }}/${{ github.repository_owner }}/cli | |
| BINARIES_DIR: ${{ runner.temp }}/binaries | |
| NOTES_FILE: ${{ runner.temp }}/CHANGELOG.md | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const finalTag = process.env.FINAL_TAG; | |
| const finalVersion = process.env.FINAL_VERSION; | |
| const rcTag = process.env.RC_TAG; | |
| const targetRepo = process.env.TARGET_REPO; | |
| const binariesDir = process.env.BINARIES_DIR; | |
| const notesFile = process.env.NOTES_FILE; | |
| let notes = fs.existsSync(notesFile) | |
| ? fs.readFileSync(notesFile, 'utf8').trim() | |
| : `Promoted from ${rcTag}`; | |
| // Replace RC header with final release header | |
| // From: "[cli/v0.17.0-rc.1] - 2026-02-02" | |
| // To: "[cli/v0.17.0] - promoted from [cli/v0.17.0-rc.1] on 2026-02-16" | |
| const today = new Date().toISOString().split('T')[0]; | |
| notes = notes.replace( | |
| /^\[([^\]]+)\]\s*-\s*[\d-]+/, | |
| `[${finalTag}] - promoted from [${rcTag}] on ${today}` | |
| ); | |
| const created = await github.rest.repos.createRelease({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| tag_name: finalTag, | |
| name: `CLI ${finalVersion}`, | |
| body: notes, | |
| prerelease: false, | |
| }); | |
| // Upload binaries from RC release (same binaries that were tested) | |
| const binaries = fs.readdirSync(binariesDir).filter(f => f.startsWith('ocm-')); | |
| for (const file of binaries) { | |
| const filePath = path.join(binariesDir, file); | |
| const data = fs.readFileSync(filePath); | |
| await github.rest.repos.uploadReleaseAsset({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| release_id: created.data.id, | |
| name: file, | |
| data, | |
| headers: { | |
| 'content-type': 'application/octet-stream', | |
| }, | |
| }); | |
| } | |
| const releaseUrl = created.data.html_url; | |
| await core.summary | |
| .addHeading('Final Release Published') | |
| .addTable([ | |
| [{data: 'Field', header: true}, {data: 'Value', header: true}], | |
| ['Final Tag', finalTag], | |
| ['Promoted from RC', rcTag], | |
| ['OCI Tags', `${targetRepo}:${finalVersion}, ${targetRepo}:latest`], | |
| ]) | |
| .addEOL() | |
| .addLink(releaseUrl, releaseUrl) | |
| .addEOL() | |
| .write(); | |