Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions .github/workflows/tag-release-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
name: Tag Release Candidate in GAR

on:
workflow_dispatch:
inputs:
commit_sha:
description: "Full 40-character commit SHA from a merged main commit (copy from the commit page on GitHub or `git log`)"
required: true
type: string
rc_number:
description: "Release candidate number, e.g. 1 for vX.Y.Z-rc.1. Increment for each new candidate of the same version"
required: true
type: string

concurrency:
# Shared with the stable promotion workflow so the two cannot mutate tags at the same time
group: mlpa-image-promotion
cancel-in-progress: false

env:
GAR_LOCATION: us
GCP_PROJECT_ID: moz-fx-llm-proxy-prod-b0b8
GAR_REPOSITORY: llm-proxy-prod
GAR_NAME: mlpa
GCPV2_GITHUB_WORKLOAD_IDENTITY_PROVIDER: projects/324168772199/locations/global/workloadIdentityPools/github-actions/providers/github-actions

jobs:
tag-rc:
runs-on: ubuntu-latest
environment: build
timeout-minutes: 10
permissions:
contents: read
id-token: write

steps:
- name: Validate inputs
env:
COMMIT_SHA: ${{ inputs.commit_sha }}
RC_NUMBER: ${{ inputs.rc_number }}
run: |
set -euo pipefail
if [[ ! "$COMMIT_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::commit_sha must be a full 40-character hex SHA, got: '$COMMIT_SHA'"
exit 1
fi
if [[ ! "$RC_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::rc_number must be a positive integer with no leading zeros, got: '$RC_NUMBER'"
exit 1
fi

- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.commit_sha }}
# Full history: needed to fetch and compare against `origin/main` below
fetch-depth: 0
persist-credentials: false

# The short-SHA image tags we look up are only created by pushes to `main`
# We confirm the candidate actually landed there before looking it up in GAR
- name: Verify candidate is on main
env:
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail

# The explicit refspec guarantees `origin/main` is up-to-date locally
git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"

if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/main; then
echo "::error::Commit $COMMIT_SHA is not on main. Has it been merged?"
exit 1
fi

- name: Derive tags and version
id: tags
env:
COMMIT_SHA: ${{ inputs.commit_sha }}
RC_NUMBER: ${{ inputs.rc_number }}
run: |
set -euo pipefail

# Recompute the tag w/ the same command the build used (length can exceed 10)
SOURCE_TAG="$(git rev-parse --short=10 "$COMMIT_SHA")"

# Read the version from `pyproject.toml` as of the candidate commit
VERSION="$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")"

# Strict semver with no leading zeros, so the eventual stable vX.Y.Z promotion is a valid prod tag
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::project.version '$VERSION' is not a valid X.Y.Z version"
exit 1
fi

RC_TAG="v${VERSION}-rc.${RC_NUMBER}"

echo "source_tag=${SOURCE_TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "rc_tag=${RC_TAG}" >> "$GITHUB_OUTPUT"
echo "Source tag: ${SOURCE_TAG} -> RC tag: ${RC_TAG}"

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
service_account: artifact-writer@${{ env.GCP_PROJECT_ID }}.iam.gserviceaccount.com
workload_identity_provider: ${{ env.GCPV2_GITHUB_WORKLOAD_IDENTITY_PROVIDER }}

- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2

- name: Tag release candidate
id: tag
env:
SOURCE_TAG: ${{ steps.tags.outputs.source_tag }}
RC_TAG: ${{ steps.tags.outputs.rc_tag }}
run: |
set -euo pipefail
IMAGE="${GAR_LOCATION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GAR_REPOSITORY}/${GAR_NAME}"

# Return the image digest for a ref, or empty if it doesn't resolve.
# Errors are silenced, i.e., an empty digest does not distinguish
# a missing image from an API failure.
get_digest() {
gcloud artifacts docker images describe "$1" \
--format='value(image_summary.digest)' 2>/dev/null || true
}

# The source image must exist
SOURCE_DIGEST="$(get_digest "${IMAGE}:${SOURCE_TAG}")"
if [[ -z "$SOURCE_DIGEST" ]]; then
echo "::error::Source image ${IMAGE}:${SOURCE_TAG} could not be resolved."
echo "::error::It may not exist, or the GAR lookup may have failed. Confirm the build for this commit completed."
exit 1
fi
if [[ ! "$SOURCE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::Invalid source digest: '$SOURCE_DIGEST'"
exit 1
fi
echo "Source: ${IMAGE}:${SOURCE_TAG} -> ${SOURCE_DIGEST}"

# Use `tags list` here instead of `images describe` for the existence check:
# - `describe` cannot tell between a missing tag and an API failure
# - `list` only exits non-zero for a real failure
if ! ALL_TAGS="$(gcloud artifacts docker tags list "$IMAGE" \
--format='value(tag)')"; then
echo "::error::Failed to list tags for ${IMAGE}; aborting rather than risk moving an existing RC tag."
exit 1
fi

rc_exists=false
while IFS= read -r t; do
# Docker tags can't contain '/', so stripping any path prefix is safe
[[ -n "$t" && "${t##*/}" == "$RC_TAG" ]] && { rc_exists=true; break; }
done <<< "$ALL_TAGS"

if [[ "$rc_exists" == "true" ]]; then
RC_DIGEST="$(get_digest "${IMAGE}:${RC_TAG}")"
if [[ -z "$RC_DIGEST" ]]; then
echo "::error::RC tag ${RC_TAG} exists, but its digest could not be resolved."
echo "::error::Check GAR access and API availability, then rerun."
exit 1
elif [[ ! "$RC_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::Invalid digest for RC tag ${RC_TAG}: '$RC_DIGEST'"
exit 1
elif [[ "$RC_DIGEST" == "$SOURCE_DIGEST" ]]; then
echo "RC tag ${RC_TAG} already points to ${SOURCE_DIGEST}. Nothing to do."
else
# `tags add` would repoint the tag at this image instead. Stage pins to
# this tag, so moving it could cause stage to run a different image
# without a values-file change. We exit instead of overwriting.
echo "::error::RC tag ${RC_TAG} already points to a different image (${RC_DIGEST})."
echo "::error::Use a new RC number instead of overwriting this tag."
exit 1
fi
else
gcloud artifacts docker tags add "${IMAGE}@${SOURCE_DIGEST}" "${IMAGE}:${RC_TAG}"
echo "Tagged ${IMAGE}@${SOURCE_DIGEST} as ${IMAGE}:${RC_TAG}"
fi

echo "digest=${SOURCE_DIGEST}" >> "$GITHUB_OUTPUT"

- name: Summary
env:
COMMIT_SHA: ${{ inputs.commit_sha }}
SOURCE_TAG: ${{ steps.tags.outputs.source_tag }}
VERSION: ${{ steps.tags.outputs.version }}
RC_TAG: ${{ steps.tags.outputs.rc_tag }}
DIGEST: ${{ steps.tag.outputs.digest }}
run: |
set -euo pipefail
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## RC Tag

| Field | Value |
|---|---|
| Commit | \`${COMMIT_SHA}\` |
| Source tag | \`${SOURCE_TAG}\` |
| Version | \`${VERSION}\` |
| RC tag | \`${RC_TAG}\` |
| Digest | \`${DIGEST}\` |
EOF
7 changes: 3 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ setup: node-setup
@echo "✅ Setup complete! To activate your environment, run:"
@echo " source $(VENV)/bin/activate"

# Ensure the Node version `make docs` needs (.nvmrc). Best-effort and non-fatal:
# Redoc emits Node-specific CSS hashes, so docs must be built on this version.
# Ensure the Node version `make docs` needs (.nvmrc). Best-effort and non-fatal.
# `styled-components` (pinned in `docs`) generates the CSS hashes rather than Node.
node-setup:
@want=$$(cat .nvmrc); \
if command -v node >/dev/null 2>&1 && [ "$$(node -v | sed 's/v\([0-9]*\).*/\1/')" = "$$want" ]; then \
Expand Down Expand Up @@ -53,12 +53,11 @@ docs:
want=$$(cat .nvmrc); have=$$(node -v | sed 's/v\([0-9]*\).*/\1/'); \
if [ "$$have" != "$$want" ]; then \
echo "make docs needs Node $$want (see .nvmrc); found Node $$have."; \
echo "Redoc emits Node-specific CSS hashes, so a mismatch fails the CI docs check."; \
echo "Switch with: nvm use (or e.g. brew install node@$$want)"; \
exit 1; \
fi; \
uv run python -c "from mlpa.run import app; import json; json.dump(app.openapi(), open('openapi.json', 'w'), indent=2)" && \
npx --yes @redocly/cli@2.5.0 build-docs openapi.json -o docs/index.html && \
npx --yes -p @redocly/cli@2.5.0 -p styled-components@6.4.3 redocly build-docs openapi.json -o docs/index.html && \
rm -f openapi.json

# NOTE: for local development only
Expand Down
Loading