refactor: remove beta sync script and update promotion documentation #11
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
| # Build and push staging images from the `staging` branch. | |
| # Tags: X.Y.Z-staging.N and floating :staging. Version Git ref reserved via the Git API. | |
| name: Publish (staging) | |
| on: | |
| push: | |
| branches: | |
| - staging | |
| workflow_dispatch: | |
| inputs: | |
| version_override: | |
| description: "Optional version override (default: atomic Git tag reservation for | |
| staging line)" | |
| required: false | |
| type: string | |
| permissions: | |
| contents: read | |
| packages: read | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "24" | |
| cache: "npm" | |
| - name: Pin npm version | |
| run: npm install -g npm@11.13.0 | |
| - name: Install dependencies | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2 3 4 5; do | |
| if npm ci; then | |
| break | |
| fi | |
| echo "npm ci failed (attempt $attempt/5); retrying in 15s..." >&2 | |
| sleep 15 | |
| if [[ "$attempt" -eq 5 ]]; then | |
| exit 1 | |
| fi | |
| done | |
| - name: Security audit | |
| run: npm audit --omit=dev --audit-level=moderate | |
| - name: Build all packages | |
| run: npm run build:packages | |
| - name: Lint | |
| run: npm run lint | |
| - name: Type check | |
| run: npm run type-check | |
| - name: Setup web env for build | |
| run: test -f apps/web/.env.local || cp apps/web/.env.example apps/web/.env.local | |
| - name: Build all apps | |
| run: npm run build:apps | |
| reserve-version: | |
| needs: validate | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| outputs: | |
| version: ${{ steps.reserve.outputs.version }} | |
| float_tag: ${{ steps.reserve.outputs.float_tag }} | |
| is_prod: ${{ steps.reserve.outputs.is_prod }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Reserve next version | |
| id: reserve | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| SHA: ${{ github.sha }} | |
| OVERRIDE: ${{ inputs.version_override }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| BASE=$(node -p "require('./package.json').version" | sed 's/-.*//') | |
| SUFFIX="staging" | |
| FLOAT="staging" | |
| IS_PROD="false" | |
| # This workflow is only for the `staging` branch; ref name is not used in logic below. | |
| create_tag() { | |
| local tag="$1" | |
| local sha="$2" | |
| local body | |
| body=$(mktemp) | |
| LAST_CREATE_CODE=$(curl -sS -o "$body" -w "%{http_code}" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -X POST "https://api.github.com/repos/${REPO}/git/refs" \ | |
| -d "{\"ref\":\"refs/tags/${tag}\",\"sha\":\"${sha}\"}") | |
| LAST_CREATE_BODY=$(cat "$body") | |
| rm -f "$body" | |
| echo "create-ref attempt: tag=${tag} status=${LAST_CREATE_CODE}" | |
| if [ "$LAST_CREATE_CODE" != "201" ] && [ "$LAST_CREATE_CODE" != "422" ]; then | |
| echo "GitHub API error ${LAST_CREATE_CODE} while creating tag ${tag}:" >&2 | |
| printf '%s\n' "$LAST_CREATE_BODY" >&2 | |
| return 1 | |
| fi | |
| } | |
| if [ -n "$OVERRIDE" ]; then | |
| VERSION="$OVERRIDE" | |
| create_tag "$VERSION" "$SHA" | |
| if [ "$LAST_CREATE_CODE" = "201" ]; then | |
| echo "Reserved explicit override tag ${VERSION}." | |
| elif [ "$LAST_CREATE_CODE" = "422" ]; then | |
| echo "Override tag already exists; refusing unless same commit (not implemented for simple path); exit 1" >&2 | |
| exit 1 | |
| fi | |
| else | |
| START=0 | |
| if TAG_LINES=$(git ls-remote --tags origin "refs/tags/${BASE}-${SUFFIX}.*" 2>/dev/null); then | |
| MAX=$(printf '%s\n' "$TAG_LINES" | awk -v prefix="refs/tags/${BASE}-${SUFFIX}." ' | |
| { | |
| ref=$2 | |
| sub(/\^\{\}$/, "", ref) | |
| if (index(ref, prefix) == 1) { | |
| n=substr(ref, length(prefix) + 1) | |
| if (n ~ /^[0-9]+$/) { | |
| if (max == "" || (n + 0) > (max + 0)) { | |
| max=n + 0 | |
| } | |
| } | |
| } | |
| } | |
| END { | |
| if (max != "") { | |
| print max | |
| } | |
| } | |
| ') | |
| if [ -n "$MAX" ]; then | |
| START=$((MAX + 1)) | |
| fi | |
| echo "Smart-start candidate N=${START}." | |
| else | |
| echo "git ls-remote failed for ${BASE}-${SUFFIX}; starting at N=0." | |
| fi | |
| N=$START | |
| while :; do | |
| VERSION="${BASE}-${SUFFIX}.${N}" | |
| create_tag "$VERSION" "$SHA" | |
| if [ "$LAST_CREATE_CODE" = "201" ]; then | |
| break | |
| fi | |
| if [ "$LAST_CREATE_CODE" = "422" ]; then | |
| echo "Tag ${VERSION} exists; incrementing N." | |
| N=$((N + 1)) | |
| continue | |
| fi | |
| exit 1 | |
| done | |
| fi | |
| echo "Reserved version: $VERSION" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "float_tag=$FLOAT" >> "$GITHUB_OUTPUT" | |
| echo "is_prod=$IS_PROD" >> "$GITHUB_OUTPUT" | |
| publish-docker: | |
| needs: [validate, reserve-version] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| strategy: | |
| matrix: | |
| include: | |
| - app: api | |
| dockerfile: infra/docker/local/api/Dockerfile | |
| - app: management-api | |
| dockerfile: infra/docker/local/management-api/Dockerfile | |
| - app: web | |
| dockerfile: infra/docker/local/web/Dockerfile | |
| - app: web-sidecar | |
| dockerfile: infra/docker/local/web-sidecar/Dockerfile | |
| - app: management-web | |
| dockerfile: infra/docker/local/management-web/Dockerfile | |
| - app: management-web-sidecar | |
| dockerfile: infra/docker/local/management-web-sidecar/Dockerfile | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push Docker image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ${{ matrix.dockerfile }} | |
| push: true | |
| tags: | | |
| ghcr.io/${{ github.repository }}/${{ matrix.app }}:${{ needs.reserve-version.outputs.version }} | |
| ghcr.io/${{ github.repository }}/${{ matrix.app }}:${{ needs.reserve-version.outputs.float_tag }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| verify-published-tags: | |
| needs: [reserve-version, publish-docker] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - name: Verify expected tags in GHCR | |
| env: | |
| VERSION: ${{ needs.reserve-version.outputs.version }} | |
| FLOAT_TAG: ${{ needs.reserve-version.outputs.float_tag }} | |
| GHCR_TOKEN_PRIMARY: ${{ secrets.GHCR_REGISTRY_TOKEN }} | |
| GHCR_TOKEN_FALLBACK: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| GHCR_TOKEN="$GHCR_TOKEN_PRIMARY" | |
| if [ -z "$GHCR_TOKEN" ]; then | |
| GHCR_TOKEN="$GHCR_TOKEN_FALLBACK" | |
| echo "GHCR_REGISTRY_TOKEN not set; falling back to GITHUB_TOKEN for verification." | |
| fi | |
| APPS="api management-api web web-sidecar management-web management-web-sidecar" | |
| for APP in $APPS; do | |
| IMAGE_PATH="${{ github.repository }}/${APP}" | |
| echo "Checking tags for ${IMAGE_PATH}" | |
| TAGS_RESPONSE=$(curl -s -w "\n%{http_code}" \ | |
| -H "Authorization: Bearer $GHCR_TOKEN" \ | |
| "https://ghcr.io/v2/${IMAGE_PATH}/tags/list") | |
| TAGS_JSON=$(echo "$TAGS_RESPONSE" | sed '$d') | |
| TAGS_STATUS=$(echo "$TAGS_RESPONSE" | tail -n1) | |
| if [ "$TAGS_STATUS" != "200" ]; then | |
| echo "Tag verification failed for ${IMAGE_PATH} (HTTP ${TAGS_STATUS})." | |
| exit 1 | |
| fi | |
| if ! echo "$TAGS_JSON" | jq -e --arg VERSION "$VERSION" '.tags | index($VERSION)' >/dev/null; then | |
| echo "Missing version tag ${VERSION} for ${IMAGE_PATH}." | |
| exit 1 | |
| fi | |
| if ! echo "$TAGS_JSON" | jq -e --arg T "$FLOAT_TAG" '.tags | index($T)' >/dev/null; then | |
| echo "Missing floating tag ${FLOAT_TAG} for ${IMAGE_PATH}." | |
| exit 1 | |
| fi | |
| done | |
| workflow-summary: | |
| needs: [reserve-version, verify-published-tags] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Published image version (workflow summary) | |
| env: | |
| VERSION: ${{ needs.reserve-version.outputs.version }} | |
| REPO: ${{ github.repository }} | |
| FT: ${{ needs.reserve-version.outputs.float_tag }} | |
| run: | | |
| { | |
| echo "## Published Docker images (staging branch)" | |
| echo "" | |
| echo "**Image version (semver tag):** \`$VERSION\`" | |
| echo "" | |
| echo "Each image was also tagged **\`$FT\`** (floating)." | |
| echo "" | |
| echo "| Image | Tags |" | |
| echo "|-------|------|" | |
| for app in api management-api web web-sidecar management-web management-web-sidecar; do | |
| echo "| \`ghcr.io/${REPO}/${app}\` | \`${VERSION}\`, \`${FT}\` |" | |
| done | |
| echo "" | |
| echo "Pin GitOps to the version tag or the floating tag. Git tag matches the semver when created." | |
| echo "" | |
| echo "## All published image references" | |
| for app in api management-api web web-sidecar management-web management-web-sidecar; do | |
| echo "- \`ghcr.io/${REPO}/${app}:${VERSION}\` (version tag)" | |
| echo "- \`ghcr.io/${REPO}/${app}:${FT}\` (floating tag)" | |
| done | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| github-prerelease-create: | |
| needs: [reserve-version, verify-published-tags] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Create GitHub prerelease | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const version = "${{ needs.reserve-version.outputs.version }}"; | |
| const body = | |
| "Prerelease " + | |
| version + | |
| ". Add or edit release notes on GitHub if needed."; | |
| const tag = version; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const { data: list } = await github.rest.repos.listReleases({ owner, repo, per_page: 100 }); | |
| if (list.some((r) => r.tag_name === tag)) { | |
| core.info("Release for tag " + tag + " already exists; skip."); | |
| return; | |
| } | |
| const rel = await github.rest.repos.createRelease({ | |
| owner, repo, tag_name: tag, name: tag, body, prerelease: true, | |
| }); | |
| core.info("Created release: " + rel.data.html_url); |