|
| 1 | +# reusable-promote.yml — shared promote-testing-to-main logic |
| 2 | +# |
| 3 | +# Consolidates the per-image promote-testing-to-main.yml workflows: |
| 4 | +# projectbluefin/bluefin (~343 LoC) → thin caller (~30 LoC) |
| 5 | +# projectbluefin/bluefin-lts (~349 LoC) → thin caller (~30 LoC) |
| 6 | +# projectbluefin/dakota (~183 LoC) → thin caller (~30 LoC) |
| 7 | +# |
| 8 | +# Implements consistency-audit C1 from projectbluefin/common. |
| 9 | + |
| 10 | +name: reusable-promote |
| 11 | + |
| 12 | +on: |
| 13 | + workflow_call: |
| 14 | + inputs: |
| 15 | + variants: |
| 16 | + description: >- |
| 17 | + JSON array of variant names, |
| 18 | + e.g. '["dakota","dakota-nvidia"]' |
| 19 | + required: true |
| 20 | + type: string |
| 21 | + cosign_identity_regexp: |
| 22 | + description: >- |
| 23 | + Regex for cosign certificate identity, |
| 24 | + e.g. '^https://github\.com/projectbluefin/(dakota|actions)/\.github/workflows/' |
| 25 | + required: true |
| 26 | + type: string |
| 27 | + registry: |
| 28 | + description: 'GHCR registry prefix' |
| 29 | + required: false |
| 30 | + type: string |
| 31 | + default: 'ghcr.io/projectbluefin' |
| 32 | + run_e2e: |
| 33 | + description: 'Whether the release-gate should require a passing e2e run' |
| 34 | + required: false |
| 35 | + type: boolean |
| 36 | + default: false |
| 37 | + e2e_suite: |
| 38 | + description: 'testsuite suite identifier (smoke, common, lts, dakota)' |
| 39 | + required: false |
| 40 | + type: string |
| 41 | + default: 'smoke' |
| 42 | + lts_floor_days: |
| 43 | + description: 'Min days since last promotion before allowing a new one (LTS=7, others=0)' |
| 44 | + required: false |
| 45 | + type: number |
| 46 | + default: 0 |
| 47 | + |
| 48 | +permissions: |
| 49 | + contents: read |
| 50 | + |
| 51 | +concurrency: |
| 52 | + group: promote-testing-to-main-${{ github.event.repository.name }} |
| 53 | + cancel-in-progress: false |
| 54 | + |
| 55 | +jobs: |
| 56 | + promote: |
| 57 | + name: Resolve digests and maintain promotion PR |
| 58 | + runs-on: ubuntu-latest |
| 59 | + timeout-minutes: 15 |
| 60 | + permissions: |
| 61 | + contents: write |
| 62 | + packages: read |
| 63 | + pull-requests: write |
| 64 | + outputs: |
| 65 | + changed: ${{ steps.branch.outputs.changed }} |
| 66 | + pr_number: ${{ steps.pr.outputs.pr_number }} |
| 67 | + steps: |
| 68 | + - name: Checkout |
| 69 | + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 |
| 70 | + with: |
| 71 | + fetch-depth: 0 |
| 72 | + ref: main |
| 73 | + |
| 74 | + - name: Configure git identity |
| 75 | + run: | |
| 76 | + git config user.name "github-actions[bot]" |
| 77 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 78 | +
|
| 79 | + - name: Apply LTS floor (skip if promoted too recently) |
| 80 | + if: inputs.lts_floor_days > 0 |
| 81 | + id: lts-floor |
| 82 | + run: | |
| 83 | + set -euo pipefail |
| 84 | + LAST=$(git log -1 --format=%ct origin/main -- .github/release-state.yaml 2>/dev/null || echo 0) |
| 85 | + NOW=$(date +%s) |
| 86 | + AGE=$(( (NOW - LAST) / 86400 )) |
| 87 | + FLOOR=${{ inputs.lts_floor_days }} |
| 88 | + if [ "$AGE" -lt "$FLOOR" ]; then |
| 89 | + echo "::notice::LTS floor not met: ${AGE}d < ${FLOOR}d — skipping" |
| 90 | + echo "skip=true" >> "$GITHUB_OUTPUT" |
| 91 | + else |
| 92 | + echo "skip=false" >> "$GITHUB_OUTPUT" |
| 93 | + fi |
| 94 | +
|
| 95 | + - name: Authenticate to GHCR for skopeo |
| 96 | + if: steps.lts-floor.outputs.skip != 'true' |
| 97 | + env: |
| 98 | + GH_TOKEN: ${{ github.token }} |
| 99 | + run: | |
| 100 | + echo "$GH_TOKEN" | skopeo login ghcr.io \ |
| 101 | + --username "x-access-token" \ |
| 102 | + --password-stdin |
| 103 | +
|
| 104 | + - name: Resolve current :testing digests |
| 105 | + if: steps.lts-floor.outputs.skip != 'true' |
| 106 | + id: digests |
| 107 | + env: |
| 108 | + REGISTRY: ${{ inputs.registry }} |
| 109 | + VARIANTS: ${{ inputs.variants }} |
| 110 | + run: | |
| 111 | + set -euo pipefail |
| 112 | + { |
| 113 | + printf '# Managed by reusable-promote.yml — do not edit manually.\n' |
| 114 | + printf '# Merging the PR that updates this file promotes these digests to :stable.\n' |
| 115 | + printf 'testing:\n' |
| 116 | + echo "${VARIANTS}" | jq -r '.[]' | while read -r variant; do |
| 117 | + digest=$(skopeo inspect \ |
| 118 | + --format '{{.Digest}}' \ |
| 119 | + "docker://${REGISTRY}/${variant}:testing") |
| 120 | + if [ -z "$digest" ]; then |
| 121 | + echo "::error::Could not resolve digest for ${variant}:testing" |
| 122 | + exit 1 |
| 123 | + fi |
| 124 | + printf ' %s: "%s"\n' "${variant}" "${digest}" |
| 125 | + done |
| 126 | + printf 'updated_at: "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 127 | + } > /tmp/release-state.yaml |
| 128 | +
|
| 129 | + - name: Update or create promotion branch |
| 130 | + if: steps.lts-floor.outputs.skip != 'true' |
| 131 | + id: branch |
| 132 | + env: |
| 133 | + GH_TOKEN: ${{ github.token }} |
| 134 | + PROMOTION_BRANCH: auto/promote-testing-to-main |
| 135 | + run: | |
| 136 | + set -euo pipefail |
| 137 | + git fetch origin "${PROMOTION_BRANCH}" 2>/dev/null || true |
| 138 | + git checkout -B "${PROMOTION_BRANCH}" origin/main |
| 139 | +
|
| 140 | + mkdir -p .github |
| 141 | + cp /tmp/release-state.yaml .github/release-state.yaml |
| 142 | + git add .github/release-state.yaml |
| 143 | +
|
| 144 | + if git diff --cached --quiet; then |
| 145 | + echo "::notice::No digest changes — nothing to do" |
| 146 | + echo "changed=false" >> "$GITHUB_OUTPUT" |
| 147 | + exit 0 |
| 148 | + fi |
| 149 | +
|
| 150 | + git commit -m "ci: update testing image digests for stable promotion" |
| 151 | + git push --force-with-lease origin "${PROMOTION_BRANCH}" |
| 152 | + echo "changed=true" >> "$GITHUB_OUTPUT" |
| 153 | +
|
| 154 | + - name: Open or update promotion PR |
| 155 | + if: steps.branch.outputs.changed == 'true' |
| 156 | + id: pr |
| 157 | + env: |
| 158 | + GH_TOKEN: ${{ github.token }} |
| 159 | + PROMOTION_BRANCH: auto/promote-testing-to-main |
| 160 | + VARIANTS: ${{ inputs.variants }} |
| 161 | + run: | |
| 162 | + set -euo pipefail |
| 163 | + FIRST_DIGEST=$(grep -m1 '^ [a-z]' .github/release-state.yaml \ |
| 164 | + | awk '{print $2}' | tr -d '"' | cut -c8-23) |
| 165 | + PR_TITLE="ci: promote testing images to stable (${FIRST_DIGEST})" |
| 166 | +
|
| 167 | + { |
| 168 | + echo "## Promote :testing → :stable" |
| 169 | + echo "" |
| 170 | + echo "Merge this PR to publish a stable release." |
| 171 | + echo "" |
| 172 | + echo '```yaml' |
| 173 | + cat .github/release-state.yaml |
| 174 | + echo '```' |
| 175 | + } > /tmp/pr-body.md |
| 176 | +
|
| 177 | + EXISTING=$(gh pr list \ |
| 178 | + --repo "${{ github.repository }}" \ |
| 179 | + --head "${PROMOTION_BRANCH}" \ |
| 180 | + --base main \ |
| 181 | + --state open \ |
| 182 | + --json number \ |
| 183 | + --jq '.[0].number // empty') |
| 184 | +
|
| 185 | + if [ -n "$EXISTING" ]; then |
| 186 | + gh pr edit "${EXISTING}" \ |
| 187 | + --title "${PR_TITLE}" \ |
| 188 | + --body-file /tmp/pr-body.md |
| 189 | + echo "pr_number=${EXISTING}" >> "$GITHUB_OUTPUT" |
| 190 | + else |
| 191 | + PR_URL=$(gh pr create \ |
| 192 | + --repo "${{ github.repository }}" \ |
| 193 | + --head "${PROMOTION_BRANCH}" \ |
| 194 | + --base main \ |
| 195 | + --title "${PR_TITLE}" \ |
| 196 | + --body-file /tmp/pr-body.md) |
| 197 | + echo "pr_number=${PR_URL##*/}" >> "$GITHUB_OUTPUT" |
| 198 | + fi |
| 199 | +
|
| 200 | + gate: |
| 201 | + name: Release gate checks |
| 202 | + needs: [promote] |
| 203 | + if: needs.promote.outputs.changed == 'true' |
| 204 | + permissions: |
| 205 | + actions: read |
| 206 | + contents: read |
| 207 | + issues: write |
| 208 | + packages: read |
| 209 | + pull-requests: write |
| 210 | + uses: projectbluefin/actions/.github/workflows/reusable-release-gate.yml@84649d23ac4dbd9f7534c1490d8a80fb05ac2ab8 # v1 |
| 211 | + with: |
| 212 | + repo: ${{ github.repository }} |
| 213 | + pr_number: ${{ needs.promote.outputs.pr_number }} |
| 214 | + registry: ${{ inputs.registry }} |
| 215 | + variants: ${{ inputs.variants }} |
| 216 | + cosign_identity_regexp: ${{ inputs.cosign_identity_regexp }} |
| 217 | + target_tag: testing |
| 218 | + run_e2e: ${{ inputs.run_e2e }} |
| 219 | + e2e_suites: ${{ inputs.e2e_suite }} |
| 220 | + secrets: inherit |
0 commit comments