bc728a753a
The --json flag is not valid for gh pr create, only for read commands like gh pr list and gh pr view. This was causing the release workflow to fail with 'unknown flag: --json' in the Create PR step. The PR number is correctly retrieved on the line after via gh pr list, so no other change was needed. Co-Authored-By: Paperclip <noreply@paperclip.ing>
441 lines
18 KiB
YAML
441 lines
18 KiB
YAML
name: Plugin Release
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
version:
|
|
description: 'Release version (e.g. 1.0.0)'
|
|
required: true
|
|
type: string
|
|
node-version:
|
|
description: 'Node.js version to use'
|
|
required: false
|
|
type: string
|
|
default: '22'
|
|
upstream-repo:
|
|
description: 'Upstream repo to fetch appVersion from (e.g. fenio/tns-csi). Leave empty to skip.'
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
secrets:
|
|
RELEASE_APP_ID:
|
|
description: 'GitHub App ID for creating PRs (org blocks GITHUB_TOKEN from creating PRs)'
|
|
required: true
|
|
RELEASE_APP_PRIVATE_KEY:
|
|
description: 'GitHub App private key (PEM format)'
|
|
required: true
|
|
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
group: release
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
check-secrets:
|
|
runs-on: runners-privilegedescalation
|
|
outputs:
|
|
ready: ${{ steps.check.outputs.ready }}
|
|
steps:
|
|
- name: Verify RELEASE_APP_ID is configured
|
|
id: check
|
|
env:
|
|
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
|
run: |
|
|
if [ -z "$RELEASE_APP_ID" ]; then
|
|
echo "::notice::RELEASE_APP_ID org secret is not configured (see PRI-380). Release skipped — no artifacts will be created."
|
|
echo "ready=false" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "ready=true" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
ci:
|
|
needs: check-secrets
|
|
if: needs.check-secrets.outputs.ready == 'true'
|
|
uses: ./.github/workflows/plugin-ci.yaml
|
|
with:
|
|
node-version: ${{ inputs.node-version }}
|
|
|
|
check-token-permissions:
|
|
needs: check-secrets
|
|
if: needs.check-secrets.outputs.ready == 'true'
|
|
runs-on: runners-privilegedescalation
|
|
outputs:
|
|
has_write: ${{ steps.check.outputs.has_write }}
|
|
steps:
|
|
- name: Generate GitHub App token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@v3
|
|
with:
|
|
app-id: ${{ secrets.RELEASE_APP_ID }}
|
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
|
|
- name: Check write permissions via API
|
|
id: check
|
|
run: |
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-X POST \
|
|
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
"https://api.github.com/repos/${{ github.repository }}/git/refs" \
|
|
-d '{"ref":"refs/heads/_release_check","sha":"${{ github.sha }}"}')
|
|
if [ "$HTTP_CODE" = "201" ]; then
|
|
echo "::notice::Token has write permission — cleaning up test ref."
|
|
curl -s -o /dev/null -w "%{http_code}" \
|
|
-X DELETE \
|
|
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
|
"https://api.github.com/repos/${{ github.repository }}/git/refs/heads/_release_check"
|
|
echo "has_write=true" >> $GITHUB_OUTPUT
|
|
elif [ "$HTTP_CODE" = "403" ]; then
|
|
echo "::error::Token lacks write permission. Release cannot push tags or branches."
|
|
echo "has_write=false" >> $GITHUB_OUTPUT
|
|
exit 1
|
|
else
|
|
echo "::warning::Unexpected response ($HTTP_CODE) when checking write permission."
|
|
echo "has_write=false" >> $GITHUB_OUTPUT
|
|
exit 1
|
|
fi
|
|
|
|
check-tag:
|
|
needs: check-secrets
|
|
if: needs.check-secrets.outputs.ready == 'true'
|
|
runs-on: runners-privilegedescalation
|
|
outputs:
|
|
skip: ${{ steps.check.outputs.skip }}
|
|
steps:
|
|
- name: Check if tag already exists
|
|
id: check
|
|
run: |
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-H "Authorization: Bearer ${{ github.token }}" \
|
|
"https://api.github.com/repos/${{ github.repository }}/git/refs/tags/v${{ inputs.version }}")
|
|
if [ "$HTTP_CODE" = "200" ]; then
|
|
echo "::notice::Tag v${{ inputs.version }} already exists. Release skipped (not an error)."
|
|
echo "skip=true" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "skip=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
release:
|
|
needs: [ci, check-tag, check-secrets, check-token-permissions]
|
|
if: needs.check-secrets.outputs.ready == 'true' && needs.check-tag.outputs.skip != 'true' && needs.check-token-permissions.outputs.has_write == 'true'
|
|
runs-on: runners-privilegedescalation
|
|
timeout-minutes: 10
|
|
|
|
steps:
|
|
- name: Validate version format
|
|
run: |
|
|
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "Error: Version must be in X.Y.Z format"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Detect package manager
|
|
id: pkg-manager
|
|
run: |
|
|
if [ -f "pnpm-lock.yaml" ]; then
|
|
echo "manager=pnpm" >> $GITHUB_OUTPUT
|
|
echo "lockfile=pnpm-lock.yaml" >> $GITHUB_OUTPUT
|
|
# Check for packageManager field in package.json (Corepack pinning).
|
|
# pnpm/action-setup@v5 errors when packageManager is absent and no version
|
|
# is specified, so use Corepack for repos that have the field pinned and
|
|
# fall back to pnpm/action-setup with version: latest for repos that don't.
|
|
PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false")
|
|
echo "has_package_manager=$PM" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "manager=npm" >> $GITHUB_OUTPUT
|
|
echo "lockfile=package-lock.json" >> $GITHUB_OUTPUT
|
|
echo "has_package_manager=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: ${{ inputs.node-version }}
|
|
# Only enable built-in npm caching here; pnpm caching is handled below
|
|
# after pnpm is installed (corepack is not available before setup-node).
|
|
cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }}
|
|
|
|
- name: Setup pnpm (via Corepack, reads version from packageManager field)
|
|
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true'
|
|
run: |
|
|
npm install -g corepack
|
|
corepack enable pnpm
|
|
corepack install
|
|
|
|
- name: Setup pnpm (version latest)
|
|
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false'
|
|
uses: pnpm/action-setup@v5
|
|
with:
|
|
run_install: false
|
|
version: latest
|
|
|
|
- name: Get pnpm store directory
|
|
id: pnpm-store
|
|
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
|
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
|
|
|
- name: Cache pnpm store
|
|
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
|
uses: actions/cache@v5
|
|
with:
|
|
path: ${{ steps.pnpm-store.outputs.dir }}
|
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-pnpm-
|
|
|
|
- name: Configure Git
|
|
run: |
|
|
git config --global user.name "github-actions[bot]"
|
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
|
|
|
- name: Update version in package.json
|
|
run: |
|
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
|
pnpm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
|
|
else
|
|
npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
|
|
fi
|
|
|
|
- name: Update artifacthub-pkg.yml
|
|
run: |
|
|
VERSION="${{ inputs.version }}"
|
|
if [ -f artifacthub-pkg.yml ]; then
|
|
PKG_NAME=$(grep '^name:' artifacthub-pkg.yml | cut -d: -f2 | tr -d ' "')
|
|
else
|
|
PKG_NAME=$(jq -r .name package.json | sed 's|^@[^/]*/||')
|
|
fi
|
|
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${VERSION}.tar.gz"
|
|
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
|
|
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
|
|
|
- name: Update appVersion from upstream release
|
|
if: inputs.upstream-repo != ''
|
|
run: |
|
|
APP_VERSION=$(curl -sf "https://api.github.com/repos/${{ inputs.upstream-repo }}/releases/latest" | jq -r '.tag_name | ltrimstr("v")')
|
|
if [ -z "$APP_VERSION" ] || [ "$APP_VERSION" = "null" ]; then
|
|
echo "::warning::Could not fetch latest upstream release, skipping appVersion update"
|
|
else
|
|
sed -i "s|^appVersion:.*|appVersion: \"${APP_VERSION}\"|" artifacthub-pkg.yml
|
|
echo "appVersion set to ${APP_VERSION}"
|
|
fi
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
max_attempts=3
|
|
attempt=1
|
|
while [ $attempt -le $max_attempts ]; do
|
|
echo "Attempt $attempt of $max_attempts"
|
|
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
|
pnpm install --frozen-lockfile && break
|
|
else
|
|
npm ci && break
|
|
fi
|
|
if [ $attempt -lt $max_attempts ]; then
|
|
echo "::warning::Install step failed on attempt $attempt. Retrying in 5 seconds..."
|
|
sleep 5
|
|
fi
|
|
attempt=$((attempt + 1))
|
|
done
|
|
if [ $attempt -gt $max_attempts ]; then
|
|
echo "::error::Install step failed after $max_attempts attempts."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Build plugin
|
|
run: npx @kinvolk/headlamp-plugin build
|
|
|
|
- name: Package plugin
|
|
run: npx @kinvolk/headlamp-plugin package
|
|
|
|
- name: Prepare release tarball
|
|
run: |
|
|
VERSION="${{ inputs.version }}"
|
|
# headlamp-plugin strips the @org/ prefix when naming tarballs.
|
|
# e.g. @privilegedescalation/headlamp-argocd-plugin -> headlamp-argocd-plugin
|
|
if [ -f artifacthub-pkg.yml ]; then
|
|
PKG_NAME=$(grep '^name:' artifacthub-pkg.yml | cut -d: -f2 | tr -d ' "')
|
|
else
|
|
PKG_NAME=$(jq -r .name package.json | sed 's|^@[^/]*/||')
|
|
fi
|
|
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
|
|
for f in *.tar.gz; do
|
|
[ "$f" != "$TARBALL" ] && mv "$f" "$TARBALL"
|
|
done
|
|
if [ ! -f "$TARBALL" ]; then
|
|
echo "Error: Expected tarball $TARBALL not found"
|
|
ls -la *.tar.gz 2>/dev/null || echo "No .tar.gz files found"
|
|
exit 1
|
|
fi
|
|
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
|
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
|
|
|
|
- name: Validate tarball
|
|
run: |
|
|
echo "Tarball: ${{ env.TARBALL }}"
|
|
ls -lh "${{ env.TARBALL }}"
|
|
tar -tzf "${{ env.TARBALL }}" | head -20
|
|
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
|
|
|
|
- name: Compute checksum
|
|
run: |
|
|
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
|
|
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
|
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
|
|
|
- name: Commit and tag
|
|
run: |
|
|
VERSION="${{ inputs.version }}"
|
|
BRANCH="release/v${VERSION}"
|
|
# If the release branch already exists (e.g. from a failed prior run),
|
|
# delete it so the re-trigger can proceed cleanly. The check-tag job
|
|
# above already skips when the tag exists, so we only reach here when
|
|
# the tag does NOT exist yet — safe to remove a stale branch.
|
|
if git ls-remote --exit-code origin "refs/heads/$BRANCH" 2>/dev/null; then
|
|
echo "::notice::Branch $BRANCH already exists — deleting for clean re-trigger."
|
|
git push origin --delete "$BRANCH"
|
|
fi
|
|
git checkout -b "$BRANCH"
|
|
git add package.json "${{ steps.pkg-manager.outputs.lockfile }}" artifacthub-pkg.yml
|
|
git commit -m "release: v${VERSION}"
|
|
git tag "v${VERSION}"
|
|
git push origin "$BRANCH"
|
|
git push origin "refs/tags/v${VERSION}"
|
|
|
|
- name: Create GitHub Release
|
|
uses: softprops/action-gh-release@v2
|
|
with:
|
|
tag_name: "v${{ inputs.version }}"
|
|
files: ${{ env.TARBALL }}
|
|
fail_on_unmatched_files: false
|
|
generate_release_notes: true
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Generate GitHub App token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@v3
|
|
with:
|
|
app-id: ${{ secrets.RELEASE_APP_ID }}
|
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
|
|
- name: Install GitHub CLI
|
|
run: |
|
|
if ! command -v gh &>/dev/null; then
|
|
GH_VERSION="2.74.0"
|
|
curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o /tmp/gh.tar.gz
|
|
tar -xzf /tmp/gh.tar.gz -C /tmp
|
|
mkdir -p "$HOME/.local/bin"
|
|
mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" "$HOME/.local/bin/gh"
|
|
rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_amd64"
|
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
"$HOME/.local/bin/gh" --version
|
|
fi
|
|
|
|
- name: Create PR for version bump
|
|
run: |
|
|
set -o pipefail
|
|
VERSION="${{ inputs.version }}"
|
|
BODY=$(printf "Automated version bump and checksum update for v%s.\n\ncc @cpfarhood" "${VERSION}")
|
|
# Create PR only if an OPEN one doesn't already exist.
|
|
# Note: gh pr view also finds MERGED PRs; we must check for open ones explicitly
|
|
# so that a re-trigger after a stale-branch delete creates a fresh PR.
|
|
OPEN_PR=$(gh pr list --base main --head "release/v${VERSION}" --state open --json number --jq '.[0].number' 2>/dev/null)
|
|
if [ -z "$OPEN_PR" ]; then
|
|
gh pr create \
|
|
--title "release: v${VERSION}" \
|
|
--body "$BODY" \
|
|
--base main \
|
|
--head "release/v${VERSION}"
|
|
# Pull the number again to handle both create and pre-existing cases
|
|
OPEN_PR=$(gh pr list --base main --head "release/v${VERSION}" --state open --json number --jq '.[0].number' 2>/dev/null)
|
|
else
|
|
echo "::notice::Open PR #${OPEN_PR} for release/v${VERSION} already exists — skipping creation."
|
|
fi
|
|
# Guard: ensure we have a PR number before proceeding
|
|
if [ -z "$OPEN_PR" ]; then
|
|
echo "::error::Could not determine PR number for release/v${VERSION}."
|
|
exit 1
|
|
fi
|
|
echo "::notice::Working with PR #${OPEN_PR}"
|
|
|
|
# Check if PR was already merged (idempotency — safe to re-trigger after a stale branch)
|
|
MERGED_CHECK=$(gh pr view "$OPEN_PR" --json state --jq '.state' 2>/dev/null)
|
|
if [ "$MERGED_CHECK" = "MERGED" ]; then
|
|
echo "::notice::PR #${OPEN_PR} was already merged. Nothing to do."
|
|
exit 0
|
|
fi
|
|
# Determine whether to use --auto or not based on current status.
|
|
# Retry the status check up to 3 times with exponential back-off when
|
|
# GitHub is still computing the merge state (UNKNOWN state).
|
|
MAX_RETRIES=3
|
|
BACKOFF=3
|
|
MERGE_STATE=""
|
|
for i in $(seq 1 $MAX_RETRIES); do
|
|
MERGE_STATE=$(gh pr view "$OPEN_PR" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null)
|
|
if [ "$MERGE_STATE" != "UNKNOWN" ]; then
|
|
break
|
|
fi
|
|
if [ $i -lt $MAX_RETRIES ]; then
|
|
echo "PR merge state is UNKNOWN (GitHub still computing). Retry ${i}/${MAX_RETRIES} in ${BACKOFF}s..."
|
|
sleep $BACKOFF
|
|
BACKOFF=$((BACKOFF * 2))
|
|
fi
|
|
done
|
|
|
|
if [ "$MERGE_STATE" = "BLOCKED" ] || [ "$MERGE_STATE" = "UNKNOWN" ]; then
|
|
echo "PR is $MERGE_STATE — attempting auto-merge (safe fallback, waits for branch protection checks)."
|
|
if gh pr merge "$OPEN_PR" --auto --squash --delete-branch 2>&1; then
|
|
echo "Auto-merge initiated successfully."
|
|
else
|
|
AUTO_MERGE_ERR=$?
|
|
# If --auto failed because auto-merge is disabled for this repo
|
|
# (autoMergeAllowed: false), fall back to --admin which merges
|
|
# regardless of branch protection rules. --admin requires GitHub
|
|
# App token, not GITHUB_TOKEN, so GH_TOKEN is already correct.
|
|
if gh pr merge "$OPEN_PR" --admin --squash --delete-branch 2>&1; then
|
|
echo "Auto-merge unavailable (autoMergeAllowed: false) — merged via --admin."
|
|
else
|
|
echo "::error::Both --auto and --admin merge failed. Exiting."
|
|
exit 1
|
|
fi
|
|
fi
|
|
else
|
|
echo "PR is $MERGE_STATE — merging directly."
|
|
gh pr merge "$OPEN_PR" --squash --delete-branch
|
|
fi
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
|
|
- name: Verify checksums are consistent (main == tag == tarball)
|
|
run: |
|
|
VERSION="${{ inputs.version }}"
|
|
TARBALL_CS=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
|
|
|
|
# Checksum recorded in the tag's artifacthub-pkg.yml
|
|
TAG_CS=$(git show "v${VERSION}:artifacthub-pkg.yml" 2>/dev/null | grep "archive-checksum" | awk '{print $2}' | sed 's/sha256://')
|
|
|
|
# Checksum now on main (after PR merge)
|
|
MAIN_CS=$(git fetch origin main 2>/dev/null; git show "origin/main:artifacthub-pkg.yml" | grep "archive-checksum" | awk '{print $2}' | sed 's/sha256://')
|
|
|
|
echo "Tarball SHA256 : $TARBALL_CS"
|
|
echo "Tag artifacthub: $TAG_CS"
|
|
echo "Main artifacthub: $MAIN_CS"
|
|
|
|
FAIL=0
|
|
[ "$TARBALL_CS" != "$TAG_CS" ] && echo "ERROR: tag checksum mismatch!" && FAIL=1
|
|
[ "$TARBALL_CS" != "$MAIN_CS" ] && echo "ERROR: main checksum mismatch!" && FAIL=1
|
|
[ "$FAIL" = "1" ] && exit 1
|
|
echo "All checksums consistent — ArtifactHub will index correctly."
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
|