CLI Release from releases/v0.2(dry-run) #119
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 # cannot use env here | |
| 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: Checkout Repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| sparse-checkout: ${{ env.COMPONENT_PATH }} | |
| ref: ${{ github.event.inputs.branch }} | |
| - name: Setup git config | |
| run: | | |
| git config --global user.name "${{ github.actor }}" | |
| git config --global user.email "${{ github.actor }}@users.noreply.github.com" | |
| - name: Download changelog artifact | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 | |
| with: | |
| name: ${{ needs.prepare.outputs.changelog_artifact }} | |
| path: ${{ runner.temp }} | |
| - name: Create ${{ needs.prepare.outputs.new_tag }} | |
| id: tag | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| TAG: ${{ needs.prepare.outputs.new_tag }} | |
| CHANGELOG_FILE: ${{ runner.temp }}/CHANGELOG.md | |
| with: | |
| script: | | |
| const { execFileSync } = require("child_process"); | |
| const fs = require("fs"); | |
| const tag = process.env.TAG; | |
| const msg = fs.readFileSync(process.env.CHANGELOG_FILE, "utf8"); | |
| try { execFileSync("git", ["rev-parse", `refs/tags/${tag}`], { stdio: "pipe" }); core.info(`Tag ${tag} exists`); core.setOutput("pushed","false"); return; } catch {} | |
| fs.writeFileSync(".tagmsg", msg); | |
| execFileSync("git", ["tag", "-a", tag, "-F", ".tagmsg"]); | |
| execFileSync("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] | |
| permissions: | |
| contents: read | |
| actions: read # Needed for artifact download | |
| packages: write # Needed for pushing OCI images and provenance layers | |
| id-token: write # Needed for provenance attestation identity | |
| attestations: write # Allows storing provenance attestation | |
| uses: ./.github/workflows/cli.yml | |
| secrets: inherit | |
| with: | |
| ref: ${{ needs.prepare.outputs.new_tag }} | |
| version: ${{ needs.prepare.outputs.base_version }} | |
| # -------------------------------------------------------- | |
| # 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: Download changelog artifact | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 | |
| with: | |
| name: ${{ needs.prepare.outputs.changelog_artifact }} | |
| path: ${{ runner.temp }} | |
| - 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-* | |
| ${{ runner.temp }}/rc-build-assets/oci/* | |
| 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: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| sparse-checkout: ${{ env.COMPONENT_PATH }} | |
| fetch-depth: 0 | |
| ref: ${{ github.event.inputs.branch }} | |
| - 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 | |
| # Points final tag to same commit as RC tag (no rebuild needed) | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| FINAL_TAG: ${{ needs.prepare.outputs.promotion_tag }} | |
| with: | |
| script: | | |
| const { execFileSync } = require("child_process"); | |
| const { RC_TAG: rcTag, FINAL_TAG: finalTag } = process.env; | |
| // Pre-flight checks | |
| if (!rcTag || !finalTag) { | |
| core.setFailed("Missing RC_TAG or FINAL_TAG"); | |
| return; | |
| } | |
| // Check if final tag already exists | |
| try { | |
| execFileSync("git", ["rev-parse", `refs/tags/${finalTag}`], { stdio: "pipe" }); | |
| core.setFailed(`Tag ${finalTag} already exists`); | |
| return; | |
| } catch { | |
| // Expected: tag doesn't exist, continue | |
| } | |
| // Resolve RC commit | |
| const rcSha = execFileSync("git", ["rev-parse", `refs/tags/${rcTag}^{commit}`], { stdio: "pipe" }).toString().trim(); | |
| if (!rcSha) { | |
| core.setFailed(`Could not resolve commit for RC tag ${rcTag}`); | |
| return; | |
| } | |
| // Create and push final tag | |
| execFileSync("git", ["tag", "-a", finalTag, rcSha, "-m", `Promote ${rcTag} to ${finalTag}`]); | |
| execFileSync("git", ["push", "origin", `refs/tags/${finalTag}`]); | |
| core.info(`✅ Created final tag ${finalTag} from ${rcTag} (${rcSha.substring(0,7)})`); | |
| - 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 | |
| env: | |
| RC_VERSION: ${{ needs.prepare.outputs.new_version }} | |
| FINAL_VERSION: ${{ needs.prepare.outputs.base_version }} | |
| TARGET_REPO: ${{ env.REGISTRY }}/${{ github.repository_owner }}/cli | |
| SET_LATEST: ${{ needs.prepare.outputs.set_latest }} | |
| run: | | |
| if [ "$SET_LATEST" = "true" ]; then | |
| oras tag "${TARGET_REPO}:${RC_VERSION}" "${FINAL_VERSION}" "latest" | |
| echo "✅ Tagged :${FINAL_VERSION} and :latest" | |
| else | |
| oras tag "${TARGET_REPO}:${RC_VERSION}" "${FINAL_VERSION}" | |
| echo "⚠️ Tagged :${FINAL_VERSION} (not setting :latest)" | |
| fi | |
| # -------------------------------------------------------- | |
| # 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: | |
| - name: Get release notes from RC | |
| # Get release notes from RC release body to reuse for final release | |
| 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" | |
| - name: Download RC release assets | |
| # Download binaries and OCI tarballs from RC release assets to reuse for final release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RC_TAG: ${{ needs.prepare.outputs.new_tag }} | |
| run: | | |
| gh release download "$RC_TAG" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "ocm-*" \ | |
| --pattern "*.tar" \ | |
| --dir "${{ runner.temp }}/assets" | |
| - 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.base_version }} | |
| TARGET_REPO: ${{ env.REGISTRY }}/${{ github.repository_owner }}/cli | |
| ASSETS_DIR: ${{ runner.temp }}/assets | |
| NOTES_FILE: ${{ runner.temp }}/CHANGELOG.md | |
| SET_LATEST: ${{ needs.prepare.outputs.set_latest }} | |
| HIGHEST_FINAL_VERSION: ${{ needs.prepare.outputs.highest_final_version }} | |
| 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 assetsDir = process.env.ASSETS_DIR; | |
| const notesFile = process.env.NOTES_FILE; | |
| const setLatest = process.env.SET_LATEST === 'true'; | |
| 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}` | |
| ); | |
| // Use make_latest to control whether this release is marked as "latest" | |
| // Only set as latest if this version >= highest existing final version | |
| 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, | |
| make_latest: setLatest ? 'true' : 'false', | |
| }); | |
| core.info(setLatest | |
| ? `✅ Release marked as Latest` | |
| : `⚠️ Release NOT marked as Latest (older patch release)`); | |
| // Upload all assets from RC release (binaries + OCI tarballs) | |
| const assets = fs.readdirSync(assetsDir).filter(f => f.startsWith('ocm-') || f.endsWith('.tar')); | |
| for (const file of assets) { | |
| const filePath = path.join(assetsDir, 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; | |
| const highestFinal = process.env.HIGHEST_FINAL_VERSION || '(none)'; | |
| const ociTags = setLatest | |
| ? `${targetRepo}:${finalVersion}, ${targetRepo}:latest` | |
| : `${targetRepo}:${finalVersion}`; | |
| await core.summary | |
| .addHeading('Final Release Published') | |
| .addTable([ | |
| [{data: 'Field', header: true}, {data: 'Value', header: true}], | |
| ['Final Tag', finalTag], | |
| ['Promoted from RC', rcTag], | |
| ['Highest Final Version', highestFinal], | |
| ['OCI Tags', ociTags], | |
| ['GitHub Latest', setLatest ? 'Yes' : 'No (older version)'], | |
| ['Uploaded Assets', String(assets.length)], | |
| ]) | |
| .addEOL() | |
| .addLink(releaseUrl, releaseUrl) | |
| .addEOL() | |
| .write(); | |
| # -------------------------------------------------------- | |
| # 8. PREPARE FINAL CONSTRUCTOR: Patch RC constructor with final version | |
| # -------------------------------------------------------- | |
| prepare_final_constructor: | |
| name: Prepare Final OCM Constructor | |
| needs: [prepare, build, promote_final] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download RC constructor artifact | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 | |
| with: | |
| name: cli-constructor-artifact | |
| path: ${{ runner.temp }}/constructor | |
| - name: Patch constructor with final version | |
| env: | |
| RC_VERSION: ${{ needs.prepare.outputs.new_version }} | |
| FINAL_VERSION: ${{ needs.prepare.outputs.base_version }} | |
| CONSTRUCTOR: ${{ runner.temp }}/constructor/component-constructor.yaml | |
| IMAGE_REF: ${{ env.REGISTRY }}/${{ github.repository_owner }}/cli:${{ needs.prepare.outputs.base_version }} | |
| run: | | |
| set -euo pipefail | |
| echo "Patching constructor from RC ${RC_VERSION} to final ${FINAL_VERSION}..." | |
| # Replace all version fields with final version | |
| yq -i "(.version) = \"${FINAL_VERSION}\"" "${CONSTRUCTOR}" | |
| yq -i "(.resources[].version) = \"${FINAL_VERSION}\"" "${CONSTRUCTOR}" | |
| # Patch image reference to use final version tag | |
| export IMAGE_REF | |
| yq -i ' | |
| (.resources[] | select(.name == "image") | .access.imageReference) = env(IMAGE_REF) | |
| ' "${CONSTRUCTOR}" | |
| echo "--- Patched constructor ---" | |
| cat "${CONSTRUCTOR}" | |
| echo "---" | |
| # Verify patching was successful | |
| ACTUAL_VERSION=$(yq '.version' "${CONSTRUCTOR}") | |
| test "${ACTUAL_VERSION}" = "${FINAL_VERSION}" || { echo "❌ .version mismatch: ${ACTUAL_VERSION} != ${FINAL_VERSION}"; exit 1; } | |
| # Verify all resource versions were patched | |
| RESOURCE_VERSIONS=$(yq '[.resources[].version] | unique | .[]' "${CONSTRUCTOR}") | |
| test "${RESOURCE_VERSIONS}" = "${FINAL_VERSION}" || { echo "❌ Resource version mismatch: ${RESOURCE_VERSIONS}"; exit 1; } | |
| # Verify image reference uses final version tag | |
| ACTUAL_IMAGE_REF=$(yq '.resources[] | select(.name == "image") | .access.imageReference' "${CONSTRUCTOR}") | |
| test "${ACTUAL_IMAGE_REF}" = "${IMAGE_REF}" || { echo "❌ Image ref mismatch: ${ACTUAL_IMAGE_REF} != ${IMAGE_REF}"; exit 1; } | |
| echo "✅ Constructor patched to ${FINAL_VERSION}" | |
| - name: Upload final constructor artifact | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 | |
| with: | |
| name: cli-final-constructor-artifact | |
| if-no-files-found: error | |
| path: ${{ runner.temp }}/constructor/component-constructor.yaml | |
| # -------------------------------------------------------- | |
| # 9. PUBLISH FINAL COMPONENT: Publish final OCM component version | |
| # -------------------------------------------------------- | |
| publish_final_component: | |
| name: Publish Final OCM Component | |
| needs: [prepare, build, prepare_final_constructor, release_final] | |
| permissions: | |
| actions: read | |
| packages: write | |
| uses: ./.github/workflows/publish-ocm-component-version.yml | |
| secrets: inherit | |
| with: | |
| constructor_artifact_name: cli-final-constructor-artifact | |
| build_artifact_name: ${{ needs.build.outputs.artifact_name }} | |
| ocm_repository: ghcr.io/${{ github.repository_owner }} |