diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..b7f64f2 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,11 @@ +# actionlint configuration for the kit's own CI. +# +# The reusable workflows check out the kit itself with the job.workflow_repository +# and job.workflow_sha context properties (the called workflow's repository and +# commit, independent of the caller). GitHub documents them at +# https://docs.github.com/en/actions/reference/workflows-and-actions/contexts +# but actionlint (<= 1.7.12) does not know them yet. Ignore only that message. +paths: + .github/workflows/**/*.yaml: + ignore: + - 'property "workflow_(repository|sha)" is not defined in object type' diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml new file mode 100644 index 0000000..e50139a --- /dev/null +++ b/.github/workflows/backport.yaml @@ -0,0 +1,141 @@ +name: backport + +# shell: bash gives -eo pipefail, so a script failing before a pipe (classify | +# tee) fails the step; the implicit default is bash -e without pipefail. +defaults: + run: + shell: bash + +# Reusable workflow: cherry-pick a merged main PR into the release branch named +# by its `backport release-X.Y.Z` label(s) and open the backport PR. +# +# The caller's stub (templates/workflows/backport.yaml) runs on +# pull_request_target closed/labeled for main. One label = one target = one PR; +# several labels make several PRs. Targets that already have a PR are skipped, +# so re-labelling never duplicates. The label of a released branch no longer +# exists (GA deletes it), so a released branch cannot be targeted. +# +# On a conflict the action commits the conflicted state as a *draft* PR and +# comments the resolution steps. The last step labels such drafts +# `backport-manual` and pings Slack; the developer resets that commit and redoes +# `git cherry-pick -x` by hand. +# +# Uses the release App token, not GITHUB_TOKEN: PRs created with GITHUB_TOKEN +# do not trigger other workflows, which would leave the backport PR without +# its required checks and therefore unmergeable. +# +# Variables (all prefixed CLOUD_COMPONENT_): RELEASE_APP_ID, RELEASE_TEAM, SLACK_CHANNEL_ID. +# Secrets (inherit): CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY, SLACK_OAUTH_TOKEN. + +on: + workflow_call: + secrets: + CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY: + required: true + SLACK_OAUTH_TOKEN: + required: false + +jobs: + backport: + name: backport PR #${{ github.event.pull_request.number }} + if: > + github.event.pull_request.merged == true && + contains(toJSON(github.event.pull_request.labels.*.name), '"backport release-') + runs-on: ubuntu-latest + steps: + - name: Require release variables + env: + RELEASE_APP_ID: ${{ vars.CLOUD_COMPONENT_RELEASE_APP_ID }} + RELEASE_TEAM: ${{ vars.CLOUD_COMPONENT_RELEASE_TEAM }} + run: | + for v in RELEASE_APP_ID RELEASE_TEAM; do + [ -n "${!v}" ] || { echo "::error::variable CLOUD_COMPONENT_$v is empty; set it as an organization (or repository) variable"; exit 1; } + done + + - name: Mint release App token + id: app + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.CLOUD_COMPONENT_RELEASE_APP_ID }} + private-key: ${{ secrets.CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY }} + + # The base branch (main), not the PR head: pull_request_target runs with + # secrets, so untrusted PR code must never be checked out here. + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + + - name: Cherry-pick and open backport PRs + id: bp + uses: korthout/backport-action@v4 + with: + github_token: ${{ steps.app.outputs.token }} + label_pattern: '^backport (release-[0-9]+\.[0-9]+\.[0-9]+)$' + pull_title: '${pull_title}' + # The trailer is repeated in the PR body so it survives the squash + # merge whichever commit-message default the repository uses. + # release-notes.sh needs it on the release-branch commit to tell a + # backport from a new change. + pull_description: |- + Backport of #${pull_number} to `${target_branch}`. + + Created by the backport workflow from the merged main commit; the + cherry-pick trailer is the provenance the release-policy check verifies. + + (cherry picked from commit ${{ github.event.pull_request.merge_commit_sha }}) + add_author_as_assignee: true + add_team_reviewers: ${{ vars.CLOUD_COMPONENT_RELEASE_TEAM }} + copy_labels_pattern: '^(release-blocker|tag-rc)$' + auto_merge_enabled: true + auto_merge_method: squash + merge_commits: fail + git_committer_name: ${{ steps.app.outputs.app-slug }}[bot] + git_committer_email: ${{ steps.app.outputs.app-slug }}[bot]@users.noreply.github.com + experimental: '{"conflict_resolution":"draft_commit_conflicts"}' + + # Drafts are conflicts in this mode: label them so release-policy accepts a + # hand-resolved (non-identical) cherry-pick, and tell the channel. + - name: Label conflicting drafts and notify + if: always() + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + SLACK_OAUTH_TOKEN: ${{ secrets.SLACK_OAUTH_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.CLOUD_COMPONENT_SLACK_CHANNEL_ID }} + CREATED: ${{ steps.bp.outputs.created_pull_numbers }} + SRC: ${{ github.event.pull_request.number }} + run: | + repo=${GITHUB_REPOSITORY#*/} + src_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$SRC" + src_title=$(gh pr view "$SRC" --json title -q .title 2>/dev/null || echo "") + if [ "${{ steps.bp.outcome }}" != "success" ]; then + .release-kit/scripts/slack-notify.sh --color danger --link "$src_url|source PR" \ + --title ":x: *$repo* · backport of #$SRC failed" \ + "• $src_title + • The backport bot left a comment on the source PR with the reason" + fi + for n in $(tr ',' ' ' <<<"$CREATED"); do + [ -n "$n" ] || continue + base=$(gh pr view "$n" --json baseRefName -q .baseRefName) + pr_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$n" + if [ "$(gh pr view "$n" --json isDraft -q .isDraft)" = "true" ]; then + gh pr edit "$n" --add-label backport-manual + .release-kit/scripts/slack-notify.sh --color warning --link "$pr_url|draft PR #$n" --link "$src_url|source PR" \ + --title ":warning: *$repo* · backport #$SRC → \`$base\` has conflicts" \ + "• $src_title + • Draft PR #$n holds the conflicted state; redo the cherry-pick by hand (\`git cherry-pick -x\`), push, mark ready + • Labelled \`backport-manual\` so release-policy accepts the hand-resolved content" + else + .release-kit/scripts/slack-notify.sh --color info --link "$pr_url|PR #$n" --link "$src_url|source PR" \ + --title ":arrow_right: *$repo* · backport #$SRC → \`$base\`" \ + "• $src_title + • Auto-merge is on: one release-manager approval merges it once the required checks are green" + fi + done diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..20108b8 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,49 @@ +name: ci + +# The kit's own checks: shell scripts (shellcheck + bats), workflows and +# templates (actionlint). Runs on every PR and on main. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +env: + ACTIONLINT_VERSION: 1.7.12 + BATS_VERSION: v1.14.0 + +jobs: + shell: + name: shellcheck + bats + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install shellcheck and bats + run: | + sudo apt-get update -q + sudo apt-get install -y -q shellcheck + git clone -q --depth 1 -b "$BATS_VERSION" https://github.com/bats-core/bats-core.git /tmp/bats + sudo /tmp/bats/install.sh /usr/local + - name: shellcheck + run: shellcheck -x -P SCRIPTDIR scripts/*.sh tests/helpers.bash + - name: bats + run: | + git config --global user.name ci + git config --global user.email ci@example.com + bats tests + + workflows: + name: actionlint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install actionlint + run: | + bash <(curl -sSfL "https://raw.githubusercontent.com/rhysd/actionlint/v${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" /usr/local/bin + - name: actionlint (kit workflows) + run: actionlint -color .github/workflows/*.yaml + - name: actionlint (caller templates) + run: actionlint -color templates/workflows/*.yaml diff --git a/.github/workflows/cut-release.yaml b/.github/workflows/cut-release.yaml new file mode 100644 index 0000000..0366f94 --- /dev/null +++ b/.github/workflows/cut-release.yaml @@ -0,0 +1,143 @@ +name: cut-release + +# shell: bash gives -eo pipefail, so a script failing before a pipe (classify | +# tee) fails the step; the implicit default is bash -e without pipefail. +defaults: + run: + shell: bash + +# Reusable workflow: cut a release branch and tag vX.Y.Z-rc1. +# +# Called from a repository's .github/workflows/cut-release.yaml stub +# (templates/workflows/cut-release.yaml) on workflow_dispatch. The kind decides +# the number and the starting point (scripts/cut-release.sh): +# minor release-X.(Y+1).0 from main everything merged since the last GA +# patch release-X.Y.(Z+1) from the GA tag fixes only, backported afterwards +# major release-(X+1).0.0 from main an explicit decision +# The tag push starts the rc path of release.yaml; this workflow builds nothing. +# +# Branch and tag are pushed with the release GitHub App's token, the only actor +# (besides the release-manager team) the rulesets allow to create release-* +# branches and v* tags. Because the App bypasses the rulesets, the workflow +# itself checks that a release manager pressed the button (App permission +# "Members: read"). +# +# Variables (all prefixed CLOUD_COMPONENT_): RELEASE_APP_ID, RELEASE_TEAM, SLACK_CHANNEL_ID. +# Secrets (inherit): CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY, SLACK_OAUTH_TOKEN. + +on: + workflow_call: + inputs: + kind: + description: "minor | patch | major" + required: true + type: string + version: + description: "Override the computed X.Y.Z (first release, or an explicit number)" + required: false + default: "" + type: string + secrets: + CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY: + required: true + SLACK_OAUTH_TOKEN: + required: false + outputs: + version: + description: "X.Y.Z that was cut" + value: ${{ jobs.cut.outputs.version }} + branch: + description: "release-X.Y.Z" + value: ${{ jobs.cut.outputs.branch }} + tag: + description: "vX.Y.Z-rc1" + value: ${{ jobs.cut.outputs.tag }} + +jobs: + cut: + name: cut ${{ inputs.kind }} ${{ inputs.version }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.cut.outputs.version }} + branch: ${{ steps.cut.outputs.branch }} + tag: ${{ steps.cut.outputs.tag }} + env: + RELEASE_TEAM: ${{ vars.CLOUD_COMPONENT_RELEASE_TEAM }} + steps: + - name: Require release variables + env: + RELEASE_APP_ID: ${{ vars.CLOUD_COMPONENT_RELEASE_APP_ID }} + run: | + for v in RELEASE_APP_ID RELEASE_TEAM; do + [ -n "${!v}" ] || { echo "::error::variable CLOUD_COMPONENT_$v is empty; set it as an organization (or repository) variable"; exit 1; } + done + + - name: Mint release App token + id: app + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.CLOUD_COMPONENT_RELEASE_APP_ID }} + private-key: ${{ secrets.CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY }} + + - name: Require release-manager team membership + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + if ! gh api "orgs/${{ github.repository_owner }}/teams/${RELEASE_TEAM}/memberships/${{ github.actor }}" \ + --jq '.state' 2>/dev/null | grep -qx active; then + echo "::error::${{ github.actor }} is not an active member of ${{ github.repository_owner }}/${RELEASE_TEAM}" + exit 1 + fi + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + + - name: Cut branch, tag rc1, create backport label + id: cut + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + BOT: ${{ steps.app.outputs.app-slug }}[bot] + KIND: ${{ inputs.kind }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + git config user.name "$BOT" + git config user.email "$BOT@users.noreply.github.com" + args=("$KIND") + [ -n "$VERSION_OVERRIDE" ] && args+=(--version "$VERSION_OVERRIDE") + .release-kit/scripts/cut-release.sh "${args[@]}" + + - name: Notify Slack + if: always() + env: + SLACK_OAUTH_TOKEN: ${{ secrets.SLACK_OAUTH_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.CLOUD_COMPONENT_SLACK_CHANNEL_ID }} + BRANCH: ${{ steps.cut.outputs.branch }} + TAG: ${{ steps.cut.outputs.tag }} + KIND: ${{ inputs.kind }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + if [ "${{ job.status }}" = "success" ]; then + case $KIND in + patch) from="the newest GA tag (fixes only)";; + *) from="\`main\` (everything merged since the last GA)";; + esac + .release-kit/scripts/slack-notify.sh --color info \ + --link "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH|branch" \ + --title ":scissors: *${GITHUB_REPOSITORY#*/}* · *$BRANCH* · $KIND cut" \ + "• Branch \`$BRANCH\` created from $from + • \`$TAG\` is publishing to Harbor + • To get a fix into this release, label its \`main\` PR \`backport $BRANCH\`" + else + .release-kit/scripts/slack-notify.sh --color danger \ + --title ":x: *${GITHUB_REPOSITORY#*/}* · cut-release $KIND $VERSION_OVERRIDE failed" \ + "See the workflow run for the reason. Nothing was created." + fi diff --git a/.github/workflows/release-policy.yaml b/.github/workflows/release-policy.yaml new file mode 100644 index 0000000..a831e1d --- /dev/null +++ b/.github/workflows/release-policy.yaml @@ -0,0 +1,57 @@ +name: release-policy + +# shell: bash gives -eo pipefail, so a script failing before a pipe (classify | +# tee) fails the step; the implicit default is bash -e without pipefail. +defaults: + run: + shell: bash + +# Reusable workflow: required status check for pull requests into release-* +# branches. Proves that every change is a fix already merged on main, carried +# over unchanged (or a labelled, justified exception). See +# scripts/release-policy.sh for the seven checks. +# +# The caller's stub (templates/workflows/release-policy.yaml) runs on +# pull_request for release-* including labeled/unlabeled, because two checks +# depend on the release-only / backport-manual labels. +# +# Status-check context: " / release-policy". The stub's job id is +# release-policy, so the context the rulesets require is +# "release-policy / release-policy". Do not rename either. + +on: + workflow_call: + inputs: + generated_paths: + description: "Pathspecs excluded from the advisory size check, space separated" + required: false + default: "vendor go.sum *zz_generated*" + type: string + +jobs: + release-policy: + name: release-policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + + - name: Check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} + GENERATED_PATHS: ${{ inputs.generated_paths }} + run: .release-kit/scripts/release-policy.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..fab6256 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,471 @@ +name: release + +# shell: bash gives -eo pipefail, so a script failing before a pipe (classify | +# tee) fails the step; the implicit default is bash -e without pipefail. +defaults: + run: + shell: bash + +# Reusable workflow: publish a component from a version tag. +# +# vX.Y.Z-rcN rc path multi-arch images -> Harbor, optional chart -> Harbor +# OCI, GitHub pre-release with notes since the previous +# rc. Nothing reaches docker.io. +# vX.Y.Z GA path no build: the newest rc's images are copied by digest +# to docker.io (+ latest when it is the highest GA), the +# chart is re-packaged for docker.io OCI, release notes +# are drafted and the backport label is deleted. +# +# `classify` refuses a GA tag that is not the exact commit of the newest rc, or +# whose rc-validation status is not success (once RC_VALIDATION_ENFORCE=true), +# before anything is published: what was validated is what ships. Tags are +# created only by cut-release / tag-release with the release App token. +# +# Harbor is reachable only from the in-network runner (`runner` input); every +# job that pushes to or pulls from Harbor runs there, and arm64 is built under +# QEMU on that runner. GitHub-hosted runners do the rest. +# +# Variables (all prefixed CLOUD_COMPONENT_): HARBOR_REGISTRY (host/project), DOCKERHUB_REGISTRY, SLACK_CHANNEL_ID, +# RC_VALIDATION_ENFORCE. +# Secrets (inherit): HARBOR_USERNAME, HARBOR_PASSWORD, DOCKERHUB_USERNAME, +# DOCKERHUB_PASSWORD, SLACK_OAUTH_TOKEN. +# Caller permissions: contents: write, issues: write (label deletion), +# statuses: read (rc-validation). + +on: + workflow_call: + inputs: + images: + description: "Image names without registry, space separated, e.g. k8s-dra-driver-npu" + required: true + type: string + release_name: + description: "Prefix of the GitHub release title, e.g. RBLN DRA Driver for NPUs" + required: true + type: string + chart_dir: + description: "Helm chart directory; empty skips the chart steps" + required: false + default: "" + type: string + chart_rc_values: + description: "yq expression applied to /values.yaml before packaging the rc chart, e.g. '.image.registry = env(HARBOR_HOST)'; empty leaves values untouched" + required: false + default: "" + type: string + runner: + description: "Runner label with network access to Harbor" + required: false + default: rbln-sw-k8s-general + type: string + secrets: + HARBOR_USERNAME: + required: true + HARBOR_PASSWORD: + required: true + DOCKERHUB_USERNAME: + required: true + DOCKERHUB_PASSWORD: + required: true + SLACK_OAUTH_TOKEN: + required: false + outputs: + kind: + description: "rc | ga" + value: ${{ jobs.classify.outputs.kind }} + version: + description: "X.Y.Z" + value: ${{ jobs.classify.outputs.version }} + branch: + description: "release-X.Y.Z" + value: ${{ jobs.classify.outputs.branch }} + last_rc: + description: "Newest rc tag of this version (the tag itself on the rc path)" + value: ${{ jobs.classify.outputs.last_rc }} + sha: + description: "Commit the tag points at" + value: ${{ jobs.classify.outputs.sha }} + latest: + description: "GA path: whether latest was moved" + value: ${{ jobs.ga-promote.outputs.latest }} + digests: + description: "GA path: one =sha256:... line per image" + value: ${{ jobs.ga-promote.outputs.digests }} + draft_url: + description: "GA path: URL of the draft release" + value: ${{ jobs.draft-release.outputs.url }} + +env: + HARBOR_REGISTRY: ${{ vars.CLOUD_COMPONENT_HARBOR_REGISTRY }} + DOCKERHUB_REGISTRY: ${{ vars.CLOUD_COMPONENT_DOCKERHUB_REGISTRY }} + IMAGES: ${{ inputs.images }} + CHART_DIR: ${{ inputs.chart_dir }} + YQ_VERSION: v4.53.6 + REGCTL_VERSION: v0.11.6 + +jobs: + classify: + name: Classify ${{ github.ref_name }} + runs-on: ubuntu-latest + outputs: + kind: ${{ steps.c.outputs.kind }} + version: ${{ steps.c.outputs.version }} + branch: ${{ steps.c.outputs.branch }} + last_rc: ${{ steps.c.outputs.last_rc }} + sha: ${{ steps.c.outputs.sha }} + steps: + - name: Require registry variables + run: | + for v in HARBOR_REGISTRY DOCKERHUB_REGISTRY IMAGES; do + [ -n "${!v}" ] || { echo "::error::$v is empty; set organization variable CLOUD_COMPONENT_$v (for IMAGES: the images input)"; exit 1; } + done + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + - name: Classify tag and enforce invariants + id: c + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RC_VALIDATION_ENFORCE: ${{ vars.CLOUD_COMPONENT_RC_VALIDATION_ENFORCE }} + run: .release-kit/scripts/classify-tag.sh "$GITHUB_REF_NAME" | tee -a "$GITHUB_OUTPUT" + + # ---------------------------------------------------------------- rc path -- + + rc-images: + name: rc images (amd64 + arm64) -> Harbor + needs: classify + if: needs.classify.outputs.kind == 'rc' + runs-on: ${{ inputs.runner }} + steps: + - uses: actions/checkout@v7 + - name: Derive the Harbor host + run: echo "HARBOR_HOST=${HARBOR_REGISTRY%%/*}" >> "$GITHUB_ENV" + - name: Install prerequisites + run: | + if ! command -v docker &>/dev/null; then + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker "$USER" + sudo chmod 666 /var/run/docker.sock + fi + if ! command -v make &>/dev/null; then + sudo apt-get update && sudo apt-get install -y make + fi + - name: Set up QEMU for arm64 emulation + uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ${{ env.HARBOR_HOST }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + - name: Login to Docker Hub for base-image pulls + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Build and push (multi-arch) through the Makefile contract + run: | + set -euo pipefail + for img in $IMAGES; do + make build-image IMAGE_NAME="$HARBOR_REGISTRY/$img" VERSION="$GITHUB_REF_NAME" \ + BUILD_MULTI_PLATFORM=true PUSH_ON_BUILD=true + done + - name: Verify both platforms are present + run: | + set -euo pipefail + for img in $IMAGES; do + docker buildx imagetools inspect "$HARBOR_REGISTRY/$img:$GITHUB_REF_NAME" | tee inspect.out + for p in linux/amd64 linux/arm64; do + grep -q "$p" inspect.out || { echo "::error::$img:$GITHUB_REF_NAME is missing $p"; exit 1; } + done + done + + rc-chart: + name: rc chart -> Harbor OCI + needs: [classify, rc-images] + if: inputs.chart_dir != '' + runs-on: ${{ inputs.runner }} + env: + CHART_RC_VALUES: ${{ inputs.chart_rc_values }} + steps: + - uses: actions/checkout@v7 + - name: Derive the Harbor host + run: echo "HARBOR_HOST=${HARBOR_REGISTRY%%/*}" >> "$GITHUB_ENV" + - uses: azure/setup-helm@v5 + - name: Install yq + run: | + mkdir -p "$HOME/.local/bin" && echo "$HOME/.local/bin" >> "$GITHUB_PATH" + curl -sSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "$HOME/.local/bin/yq" + chmod +x "$HOME/.local/bin/yq" + # The rc chart must resolve this component's image from Harbor; the caller + # says how through chart_rc_values because values layouts differ per chart. + - name: Point the rc chart at Harbor + if: env.CHART_RC_VALUES != '' + run: yq -i "$CHART_RC_VALUES" "$CHART_DIR/values.yaml" + - name: Package chart + run: | + set -euo pipefail + if yq -e '.dependencies' "$CHART_DIR/Chart.yaml" >/dev/null 2>&1; then + helm dependency build "$CHART_DIR" + fi + mkdir -p dist + helm package "$CHART_DIR" --destination dist \ + --version "${GITHUB_REF_NAME#v}" --app-version "$GITHUB_REF_NAME" + - uses: actions/upload-artifact@v7 + with: + name: helm-chart-${{ github.ref_name }} + path: dist/*.tgz + - name: Push chart to Harbor + run: | + echo "${{ secrets.HARBOR_PASSWORD }}" | \ + helm registry login "$HARBOR_HOST" -u "${{ secrets.HARBOR_USERNAME }}" --password-stdin + helm push dist/*.tgz "oci://$HARBOR_REGISTRY" + + rc-prerelease: + name: rc GitHub pre-release + needs: [classify, rc-images, rc-chart] + # rc-chart is skipped without a chart; run in that case too. + if: always() && needs.rc-images.result == 'success' && (needs.rc-chart.result == 'success' || needs.rc-chart.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + - name: Release notes since the previous rc (or the previous GA) + env: + VERSION: ${{ needs.classify.outputs.version }} + run: | + set -euo pipefail + . .release-kit/scripts/lib.sh + prev=$(git tag -l "v${VERSION}-rc[0-9]*" | sort -V | awk -v cur="$GITHUB_REF_NAME" '$0 == cur { print p; exit } { p = $0 }') + [ -n "$prev" ] || prev=$(prev_ga_tag "v$VERSION") + .release-kit/scripts/release-notes.sh "$GITHUB_REF_NAME" "$prev" release_notes.md + { + echo "> Release candidate. Images${CHART_DIR:+ and chart} are on \`$HARBOR_REGISTRY\`; nothing has been published to docker.io." + echo + cat release_notes.md + } > body.md + - uses: actions/download-artifact@v8 + if: inputs.chart_dir != '' + with: + name: helm-chart-${{ github.ref_name }} + path: dist + - uses: softprops/action-gh-release@v3 + with: + prerelease: true + tag_name: ${{ github.ref_name }} + name: "${{ inputs.release_name }} ${{ github.ref_name }} (release candidate)" + body_path: body.md + files: dist/*.tgz + fail_on_unmatched_files: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + rc-notify: + name: rc summary -> Slack + needs: [classify, rc-images, rc-chart, rc-prerelease] + if: always() && needs.classify.outputs.kind == 'rc' + runs-on: ubuntu-latest + steps: + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + - name: Notify Slack + env: + SLACK_OAUTH_TOKEN: ${{ secrets.SLACK_OAUTH_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.CLOUD_COMPONENT_SLACK_CHANNEL_ID }} + R_IMAGES: ${{ needs.rc-images.result }} + R_CHART: ${{ needs.rc-chart.result }} + R_PRERELEASE: ${{ needs.rc-prerelease.result }} + BRANCH: ${{ needs.classify.outputs.branch }} + run: | + mark() { case "$1" in success) echo ":white_check_mark:";; failure) echo ":x:";; cancelled) echo ":no_entry_sign:";; skipped) echo ":fast_forward:";; *) echo ":grey_question:";; esac; } + color=good + for r in "$R_IMAGES" "$R_CHART" "$R_PRERELEASE"; do + case $r in failure|cancelled) color=danger;; esac + done + rows="$(mark "$R_IMAGES") Images → \`$HARBOR_REGISTRY\` (\`$GITHUB_REF_NAME\`, amd64 + arm64) + $(mark "$R_CHART") Helm chart → Harbor OCI + $(mark "$R_PRERELEASE") GitHub pre-release" + .release-kit/scripts/slack-notify.sh --color "$color" --details "$rows" \ + --link "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$GITHUB_REF_NAME|pre-release" \ + --title ":package: *${GITHUB_REPOSITORY#*/}* · *$GITHUB_REF_NAME* · rc published" \ + "Validate it, then \`tag-release ga\` on \`$BRANCH\` promotes exactly these digests to docker.io." + + # ---------------------------------------------------------------- GA path -- + + ga-promote: + name: GA promote rc digests -> docker.io + needs: classify + if: needs.classify.outputs.kind == 'ga' + runs-on: ${{ inputs.runner }} + outputs: + latest: ${{ steps.copy.outputs.latest }} + digests: ${{ steps.copy.outputs.digests }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + - name: Derive the Harbor host + run: echo "HARBOR_HOST=${HARBOR_REGISTRY%%/*}" >> "$GITHUB_ENV" + - name: Install regctl + run: | + mkdir -p "$HOME/.local/bin" && echo "$HOME/.local/bin" >> "$GITHUB_PATH" + curl -sSL "https://github.com/regclient/regclient/releases/download/${REGCTL_VERSION}/regctl-linux-amd64" \ + -o "$HOME/.local/bin/regctl" + chmod +x "$HOME/.local/bin/regctl" + - name: Registry logins + run: | + regctl registry login "$HARBOR_HOST" -u "${{ secrets.HARBOR_USERNAME }}" -p "${{ secrets.HARBOR_PASSWORD }}" + regctl registry login docker.io -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_PASSWORD }}" + - name: Copy the validated rc images by digest (no rebuild) + id: copy + env: + LAST_RC: ${{ needs.classify.outputs.last_rc }} + run: | + set -euo pipefail + git fetch --quiet origin '+refs/tags/*:refs/tags/*' + # shellcheck disable=SC2086 + .release-kit/scripts/promote-images.sh "$LAST_RC" "$GITHUB_REF_NAME" "$HARBOR_REGISTRY" "$DOCKERHUB_REGISTRY" $IMAGES + + ga-chart: + name: GA chart -> docker.io OCI + needs: [classify, ga-promote] + if: inputs.chart_dir != '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: azure/setup-helm@v5 + - name: Install yq + run: | + mkdir -p "$HOME/.local/bin" && echo "$HOME/.local/bin" >> "$GITHUB_PATH" + curl -sSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "$HOME/.local/bin/yq" + chmod +x "$HOME/.local/bin/yq" + - name: Package chart (pristine values) + run: | + set -euo pipefail + if yq -e '.dependencies' "$CHART_DIR/Chart.yaml" >/dev/null 2>&1; then + helm dependency build "$CHART_DIR" + fi + mkdir -p dist + helm package "$CHART_DIR" --destination dist \ + --version "${GITHUB_REF_NAME#v}" --app-version "$GITHUB_REF_NAME" + - uses: actions/upload-artifact@v7 + with: + name: helm-chart-${{ github.ref_name }} + path: dist/*.tgz + - name: Push chart to Docker Hub OCI + run: | + echo "${{ secrets.DOCKERHUB_PASSWORD }}" | \ + helm registry login docker.io -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + helm push dist/*.tgz "oci://$DOCKERHUB_REGISTRY" + + draft-release: + name: GA release notes (draft) + needs: [classify, ga-promote, ga-chart] + if: always() && needs.ga-promote.result == 'success' && (needs.ga-chart.result == 'success' || needs.ga-chart.result == 'skipped') + runs-on: ubuntu-latest + outputs: + url: ${{ steps.release.outputs.url }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + # The previous GA is the highest GA tag below this one, not "the tag before + # this one in history": release branches make git describe unreliable. + - name: Generate release notes + run: | + set -euo pipefail + . .release-kit/scripts/lib.sh + prev=$(prev_ga_tag "$GITHUB_REF_NAME") + echo "previous GA: ${prev:-}" + .release-kit/scripts/release-notes.sh "$GITHUB_REF_NAME" "$prev" release_notes.md + - uses: actions/download-artifact@v8 + if: inputs.chart_dir != '' + with: + name: helm-chart-${{ github.ref_name }} + path: dist + - uses: softprops/action-gh-release@v3 + id: release + with: + draft: true + tag_name: ${{ github.ref_name }} + name: "${{ inputs.release_name }} ${{ github.ref_name }} Release" + body_path: release_notes.md + files: dist/*.tgz + fail_on_unmatched_files: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + ga-finish: + name: GA close-out + needs: [classify, ga-promote, ga-chart, draft-release] + if: always() && needs.classify.outputs.kind == 'ga' + runs-on: ubuntu-latest + steps: + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + # No label, no bot: backports to a released branch become impossible. + - name: Delete the backport label + if: needs.ga-promote.result == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh label delete "backport ${{ needs.classify.outputs.branch }}" --yes --repo "$GITHUB_REPOSITORY" || echo "::notice::label already gone" + - name: Notify Slack + env: + SLACK_OAUTH_TOKEN: ${{ secrets.SLACK_OAUTH_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.CLOUD_COMPONENT_SLACK_CHANNEL_ID }} + R_PROMOTE: ${{ needs.ga-promote.result }} + R_CHART: ${{ needs.ga-chart.result }} + R_NOTES: ${{ needs.draft-release.result }} + DRAFT_URL: ${{ needs.draft-release.outputs.url }} + LAST_RC: ${{ needs.classify.outputs.last_rc }} + LATEST: ${{ needs.ga-promote.outputs.latest }} + BRANCH: ${{ needs.classify.outputs.branch }} + run: | + mark() { case "$1" in success) echo ":white_check_mark:";; failure) echo ":x:";; cancelled) echo ":no_entry_sign:";; skipped) echo ":fast_forward:";; *) echo ":grey_question:";; esac; } + color=good + for r in "$R_PROMOTE" "$R_CHART" "$R_NOTES"; do + case $r in failure|cancelled) color=danger;; esac + done + tags="\`$GITHUB_REF_NAME\`"; [ "$LATEST" = true ] && tags="$tags, \`latest\`" + rows="$(mark "$R_PROMOTE") Images promoted from \`$LAST_RC\` by digest → \`$DOCKERHUB_REGISTRY\` ($tags) + $(mark "$R_CHART") Helm chart \`${GITHUB_REF_NAME#v}\` → docker.io OCI + $(mark "$R_NOTES") Release notes drafted + Backport label \`backport $BRANCH\` removed; the branch is read-only now" + links=() + [ -n "$DRAFT_URL" ] && links+=(--link "$DRAFT_URL|draft release") + .release-kit/scripts/slack-notify.sh --color "$color" ${links[@]+"${links[@]}"} --details "$rows" \ + --title ":rocket: *${GITHUB_REPOSITORY#*/}* · *$GITHUB_REF_NAME* · GA" \ + "*Release manager:* review and publish the draft release." diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml new file mode 100644 index 0000000..78312b1 --- /dev/null +++ b/.github/workflows/tag-release.yaml @@ -0,0 +1,146 @@ +name: tag-release + +# shell: bash gives -eo pipefail, so a script failing before a pipe (classify | +# tee) fails the step; the implicit default is bash -e without pipefail. +defaults: + run: + shell: bash + +# Reusable workflow: tag the HEAD of a release branch as the next rc, or as GA. +# +# Two ways in, both through the caller's stub (templates/workflows/tag-release.yaml): +# - a release manager runs the stub from the Actions tab (`kind` = rc or ga) +# - a PR labelled `tag-rc` is merged into a release-* branch: the next rc is +# tagged automatically, so the release manager's approval doubles as the +# "publish an rc now" decision. GA is never automatic. +# +# The tag push is what starts release.yaml; this workflow publishes nothing. +# scripts/tag-release.sh refuses to tag when HEAD already carries an rc tag, +# when the version already went GA, or (for ga) when HEAD is not the newest +# rc's commit or that commit's rc-validation status is not success (enforced +# once the repository variable RC_VALIDATION_ENFORCE is "true"). Reading the +# status needs the App permission "Commit statuses: read". +# +# Variables (all prefixed CLOUD_COMPONENT_): RELEASE_APP_ID, RELEASE_TEAM, SLACK_CHANNEL_ID, RC_VALIDATION_ENFORCE. +# Secrets (inherit): CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY, SLACK_OAUTH_TOKEN. + +on: + workflow_call: + inputs: + branch: + description: "Release branch, e.g. release-0.7.0" + required: true + type: string + kind: + description: "rc | ga" + required: true + type: string + secrets: + CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY: + required: true + SLACK_OAUTH_TOKEN: + required: false + outputs: + tag: + description: "The tag that was created" + value: ${{ jobs.tag.outputs.tag }} + +jobs: + tag: + name: tag ${{ inputs.branch }} (${{ inputs.kind }}) + # The github context is the caller's: dispatch always runs; a PR event only + # when the PR merged with the tag-rc label. + if: > + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'tag-rc')) + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + env: + RELEASE_TEAM: ${{ vars.CLOUD_COMPONENT_RELEASE_TEAM }} + BRANCH: ${{ inputs.branch }} + KIND: ${{ inputs.kind }} + steps: + - name: Require release variables + env: + RELEASE_APP_ID: ${{ vars.CLOUD_COMPONENT_RELEASE_APP_ID }} + run: | + for v in RELEASE_APP_ID RELEASE_TEAM; do + [ -n "${!v}" ] || { echo "::error::variable CLOUD_COMPONENT_$v is empty; set it as an organization (or repository) variable"; exit 1; } + done + + - name: Mint release App token + id: app + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.CLOUD_COMPONENT_RELEASE_APP_ID }} + private-key: ${{ secrets.CLOUD_COMPONENT_RELEASE_APP_PRIVATE_KEY }} + + - name: Require release-manager team membership + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + run: | + if ! gh api "orgs/${{ github.repository_owner }}/teams/${RELEASE_TEAM}/memberships/${{ github.actor }}" \ + --jq '.state' 2>/dev/null | grep -qx active; then + echo "::error::${{ github.actor }} is not an active member of ${{ github.repository_owner }}/${RELEASE_TEAM}" + exit 1 + fi + + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + + - name: Check out the release kit + uses: actions/checkout@v7 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .release-kit + + - name: Tag + id: tag + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + BOT: ${{ steps.app.outputs.app-slug }}[bot] + RC_VALIDATION_ENFORCE: ${{ vars.CLOUD_COMPONENT_RC_VALIDATION_ENFORCE }} + run: | + git config user.name "$BOT" + git config user.email "$BOT@users.noreply.github.com" + # Keep the guard's ERROR line for the Slack message. + set +e + .release-kit/scripts/tag-release.sh "$BRANCH" "$KIND" 2>&1 | tee tag.log + rc=${PIPESTATUS[0]} + echo "reason=$(grep -m1 '^ERROR: ' tag.log | sed 's/^ERROR: //')" >> "$GITHUB_OUTPUT" + exit "$rc" + + - name: Notify Slack + if: always() + env: + SLACK_OAUTH_TOKEN: ${{ secrets.SLACK_OAUTH_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.CLOUD_COMPONENT_SLACK_CHANNEL_ID }} + TAG: ${{ steps.tag.outputs.tag }} + REASON: ${{ steps.tag.outputs.reason }} + run: | + repo=${GITHUB_REPOSITORY#*/} + trigger="${{ github.actor }}" + [ "${{ github.event_name }}" = "pull_request" ] && trigger="merge of PR #${{ github.event.pull_request.number }} (\`tag-rc\`)" + if [ "${{ job.status }}" = "success" ]; then + if [ "$KIND" = ga ]; then + .release-kit/scripts/slack-notify.sh --color good --title ":rocket: *$repo* · *$TAG* · GA tagged on \`$BRANCH\`" \ + "• Triggered by $trigger + • release.yaml is promoting the validated rc by digest to docker.io" + else + .release-kit/scripts/slack-notify.sh --color info --title ":label: *$repo* · *$TAG* · new release candidate on \`$BRANCH\`" \ + "• Triggered by $trigger + • release.yaml is publishing to Harbor" + fi + else + .release-kit/scripts/slack-notify.sh --color danger --title ":x: *$repo* · tag-release $KIND on \`$BRANCH\` refused" \ + "• Triggered by $trigger + • ${REASON:-No guard message captured; see the workflow run}" + fi diff --git a/README.md b/README.md index daa3096..88935c1 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ -# cloud-component-release-kit \ No newline at end of file +# cloud-component-release-kit + +Reusable GitHub Actions workflows and scripts for releasing RBLN-SW cloud components. + +- `cut-release` creates `release-X.Y.Z` and tags `vX.Y.Z-rc1`. +- `vX.Y.Z-rcN` tags publish images (and an optional Helm chart) to the staging registry. +- Fixes land on `main` and are backported with the `backport release-X.Y.Z` label. +- `tag-release ga` tags `vX.Y.Z`; the rc images are promoted by digest to the public registry. + +## Layout + +``` +.github/workflows/ reusable workflows (workflow_call): cut-release, tag-release, backport, release-policy, release +scripts/ shell scripts behind the workflows +rulesets/ branch and tag protection, applied with scripts/apply-rulesets.sh +templates/workflows/ caller stubs to copy into a repository's .github/workflows/ +tests/ bats tests for the scripts +``` + +## Using it in a repository + +1. Copy `templates/workflows/*.yaml` into `.github/workflows/` and fill in the `with:` values. +2. Provide a `make build-image IMAGE_NAME=/ VERSION= BUILD_MULTI_PLATFORM=true PUSH_ON_BUILD=true` target. +3. Set the `CLOUD_COMPONENT_*` variables and secrets referenced in the workflow headers. +4. Run `scripts/setup-labels.sh` and `scripts/apply-rulesets.sh` for the repository. + +## Development + +``` +bats tests +shellcheck -x -P SCRIPTDIR scripts/*.sh tests/helpers.bash +actionlint .github/workflows/*.yaml templates/workflows/*.yaml +``` + +Consumers pin the moving major tag `v1`. diff --git a/rulesets/protect-main.json b/rulesets/protect-main.json new file mode 100644 index 0000000..37ec295 --- /dev/null +++ b/rulesets/protect-main.json @@ -0,0 +1,32 @@ +{ + "name": "protect-main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [] + } + } + ] +} diff --git a/rulesets/release-branches.json b/rulesets/release-branches.json new file mode 100644 index 0000000..af3ee64 --- /dev/null +++ b/rulesets/release-branches.json @@ -0,0 +1,44 @@ +{ + "name": "release-branches", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { "actor_id": "__TEAM_ID__", "actor_type": "Team", "bypass_mode": "pull_request" }, + { "actor_id": "__APP_ID__", "actor_type": "Integration", "bypass_mode": "always" } + ], + "conditions": { + "ref_name": { "include": ["refs/heads/release-*"], "exclude": [] } + }, + "rules": [ + { "type": "creation" }, + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash"], + "required_reviewers": [ + { + "file_patterns": ["**/*"], + "minimum_approvals": 1, + "reviewer": { "id": "__TEAM_ID__", "type": "Team" } + } + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": true, + "required_status_checks": [] + } + } + ] +} diff --git a/rulesets/release-tags.json b/rulesets/release-tags.json new file mode 100644 index 0000000..bb3eb30 --- /dev/null +++ b/rulesets/release-tags.json @@ -0,0 +1,17 @@ +{ + "name": "release-tags", + "target": "tag", + "enforcement": "active", + "bypass_actors": [ + { "actor_id": "__TEAM_ID__", "actor_type": "Team", "bypass_mode": "always" }, + { "actor_id": "__APP_ID__", "actor_type": "Integration", "bypass_mode": "always" } + ], + "conditions": { + "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } + }, + "rules": [ + { "type": "creation" }, + { "type": "update", "parameters": { "update_allows_fetch_and_merge": false } }, + { "type": "deletion" } + ] +} diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh new file mode 100755 index 0000000..d9ec800 --- /dev/null +++ b/scripts/apply-rulesets.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Apply the release rulesets in rulesets/ to a repository. +# +# scripts/apply-rulesets.sh --team --app-id \ +# [--repo ] [--checks "[@],..."] [--dry-run] +# +# Rulesets are kept as JSON so the same protection can be reviewed in a PR and +# replayed on every repository. Placeholders are resolved here: +# __TEAM_ID__ the team slug becomes its numeric id +# __APP_ID__ the GitHub App's id (Settings > Developer settings > GitHub +# Apps), not the installation id +# required_status_checks filled from --checks. Each entry is a status-check +# context, optionally "@"; the default +# integration is GitHub Actions (15368). Reusable-workflow jobs +# report as " / ". The +# release-branches ruleset always requires +# "release-policy / release-policy". A ruleset left with no +# checks drops the required_status_checks rule. +# +# An existing ruleset with the same name is updated in place (PUT), otherwise +# it is created (POST). If the API rejects the `required_reviewers` parameter, +# the ruleset is applied again without it and a warning tells you to require +# the team via CODEOWNERS on the release branch instead. +# +# Requires: gh authenticated with admin rights on the repository. + +set -euo pipefail +rulesets_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../rulesets" && pwd)" + +usage="usage: apply-rulesets.sh --team --app-id [--repo owner/repo] [--checks ctx[@id],...] [--dry-run]" +team="" app_id="" repo="" checks="" dry_run=0 +while [ $# -gt 0 ]; do + case $1 in + --team) team=$2; shift 2 ;; + --app-id) app_id=$2; shift 2 ;; + --repo) repo=$2; shift 2 ;; + --checks) checks=$2; shift 2 ;; + --dry-run) dry_run=1; shift ;; + *) echo "unknown argument: $1" >&2; echo "$usage" >&2; exit 2 ;; + esac +done +if [ -z "$team" ] || [ -z "$app_id" ]; then echo "$usage" >&2; exit 2; fi +[ -n "$repo" ] || repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) +org=${repo%%/*} + +team_id=$(gh api "orgs/$org/teams/$team" --jq .id) || { echo "team $org/$team not found" >&2; exit 1; } +[ -n "$team_id" ] || { echo "team $org/$team not found" >&2; exit 1; } +echo "repo=$repo team=$team (id $team_id) app_id=$app_id checks=${checks:-}" + +existing=$(gh api "repos/$repo/rulesets" --jq '.[] | "\(.name) \(.id)"' 2>/dev/null || true) + +# checks_json [...]: JSON array for required_status_checks, built +# from --checks plus the extras, de-duplicated. +checks_json() { + { + for x in "$@"; do echo "$x"; done + tr ',' '\n' <<<"$checks" + } | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d' | sort -u | jq -R -s ' + split("\n") | map(select(length > 0)) | + map(if test("@[0-9]+$") then {context: sub("@[0-9]+$"; ""), integration_id: (capture("@(?[0-9]+)$").id | tonumber)} + else {context: ., integration_id: 15368} end)' +} + +apply() { # + local name=$1 body=$2 id method path + id=$(awk -v n="$name" '$1 == n { print $2 }' <<<"$existing") + if [ -n "$id" ]; then method=PUT; path="repos/$repo/rulesets/$id"; else method=POST; path="repos/$repo/rulesets"; fi + if [ "$dry_run" = 1 ]; then + echo "--- $method $path"; jq . <<<"$body"; return 0 + fi + gh api -X "$method" "$path" --input - <<<"$body" >/dev/null +} + +for f in "$rulesets_dir"/*.json; do + name=$(jq -r .name "$f") + body=$(sed -e "s/\"__TEAM_ID__\"/$team_id/g" -e "s/\"__APP_ID__\"/$app_id/g" "$f") + if jq -e '.rules[] | select(.type == "required_status_checks")' <<<"$body" >/dev/null; then + if [ "$name" = release-branches ]; then + cj=$(checks_json "release-policy / release-policy") + else + cj=$(checks_json) + fi + body=$(jq --argjson checks "$cj" ' + if ($checks | length) == 0 then .rules |= map(select(.type != "required_status_checks")) + else (.rules[] | select(.type == "required_status_checks") | .parameters.required_status_checks) = $checks end' <<<"$body") + fi + if apply "$name" "$body"; then + echo "ok $name" + continue + fi + if jq -e '.rules[] | select(.type == "pull_request") | .parameters.required_reviewers' <<<"$body" >/dev/null 2>&1; then + echo "warn $name: retrying without pull_request.required_reviewers" + body=$(jq '(.rules[] | select(.type == "pull_request") | .parameters) |= del(.required_reviewers)' <<<"$body") + if apply "$name" "$body"; then + echo "ok $name (without required_reviewers). Add '* @$org/$team' to .github/CODEOWNERS on release branches and set require_code_owner_review, or upgrade the API." + continue + fi + fi + echo "FAIL $name" >&2 + exit 1 +done diff --git a/scripts/classify-tag.sh b/scripts/classify-tag.sh new file mode 100755 index 0000000..4bc028e --- /dev/null +++ b/scripts/classify-tag.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Classify a pushed version tag for the release workflow and enforce the +# invariants that make "what was validated is what ships" true. +# +# scripts/classify-tag.sh # prints key=value lines for $GITHUB_OUTPUT +# +# vX.Y.Z-rcN -> kind=rc the tag must sit on release-X.Y.Z +# vX.Y.Z -> kind=ga additionally, the tag must be the exact commit of +# the newest rc for X.Y.Z, and that commit must carry +# a passing `rc-validation` commit status (posted by +# whatever validates rcs for this repository). +# Anything else fails here, before a single artifact +# is published. +# +# The status check is enforced when RC_VALIDATION_ENFORCE=true and only warns +# otherwise, so this gate can ship before a validation pipeline reports. +# Only key=value lines go to stdout (the workflow tees stdout into +# $GITHUB_OUTPUT); everything else goes to stderr. + +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +tag=${1:?usage: classify-tag.sh } +[[ $tag =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-rc([0-9]+))?$ ]] || + fail "tag must be vX.Y.Z or vX.Y.Z-rcN (got '$tag')" +version=${BASH_REMATCH[1]} +rcn=${BASH_REMATCH[3]:-} +branch="release-$version" + +git fetch --quiet origin "refs/heads/$branch:refs/remotes/origin/$branch" '+refs/tags/*:refs/tags/*' || + fail "branch $branch does not exist on origin; version tags live on release branches only" +sha=$(git rev-parse "$tag^{commit}") +git merge-base --is-ancestor "$sha" "origin/$branch" || + fail "$tag (${sha:0:12}) is not a commit of $branch" + +if [ -n "$rcn" ]; then + kind=rc + last_rc=$tag +else + kind=ga + last_rc=$(last_rc_tag "$version") + [ -n "$last_rc" ] || fail "no rc tag for $version; GA promotes a validated rc, it does not build" + rc_sha=$(git rev-parse "$last_rc^{commit}") + [ "$rc_sha" = "$sha" ] || + fail "GA tag $tag (${sha:0:12}) is not the commit of $last_rc (${rc_sha:0:12}). Tag a new rc, validate it, then GA. Nothing was published." + + state=$(commit_status_state "$sha" rc-validation) + if [ "$state" = success ]; then + echo "ok rc-validation on $last_rc (${sha:0:12}) is success" >&2 + else + msg="rc-validation status on $last_rc (${sha:0:12}) is '$state', not 'success'" + if [ "${RC_VALIDATION_ENFORCE:-}" = true ]; then + fail "$msg. 'error' means the validation infrastructure failed (rebuild it); 'failure' means the rc is broken (fix on main, cut a new rc); 'none' means it never ran. Nothing was published." + fi + echo "::warning::$msg; continuing because RC_VALIDATION_ENFORCE is not 'true'" >&2 + fi +fi + +printf 'kind=%s\nversion=%s\nbranch=%s\nlast_rc=%s\nsha=%s\n' \ + "$kind" "$version" "$branch" "$last_rc" "$sha" diff --git a/scripts/cut-release.sh b/scripts/cut-release.sh new file mode 100755 index 0000000..1a62654 --- /dev/null +++ b/scripts/cut-release.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Cut a release branch and tag its first release candidate. +# +# scripts/cut-release.sh [--version X.Y.Z] +# +# The kind decides both the number and where the branch starts: +# minor release-X.(Y+1).0 from origin/main everything merged since the last GA +# patch release-X.Y.(Z+1) from the newest GA tag fixes only, arriving as backports +# major release-(X+1).0.0 from origin/main an explicit decision (e.g. 1.0) +# --version overrides the computed number; the kind still decides the start. +# +# Creates release-X.Y.Z, tags the same commit vX.Y.Z-rc1, pushes both, and +# creates the "backport release-X.Y.Z" label the backport workflow reacts to. +# The rc tag push is what starts the rc path of the release workflow; this +# script itself builds nothing. +# +# A patch is refused unless its parent is the newest GA: published versions +# must stay in linear order (the latest alias and any upgrade graph built from +# the tags assume it). Requires push rights on origin (the release GitHub App +# in CI, or a bypass-listed release manager locally) and an authenticated gh. + +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage="usage: cut-release.sh [--version X.Y.Z]" +kind=${1:?$usage} +shift +version="" +while [ $# -gt 0 ]; do + case $1 in + --version) version=${2:?$usage}; shift 2 ;; + *) fail "unknown argument: $1 ($usage)" ;; + esac +done +[[ $kind =~ ^(minor|patch|major)$ ]] || fail "kind must be minor, patch or major (got '$kind')" + +git fetch --quiet origin main '+refs/tags/*:refs/tags/*' +last=$(last_ga_tag) + +case $kind in +patch) + [ -n "$last" ] || fail "no GA tag on origin; there is nothing to patch" + from=$last + ;; +minor | major) + from=origin/main + ;; +esac +sha=$(git rev-parse --verify "$from^{commit}") + +if [ -z "$version" ]; then + [ -n "$last" ] || fail "no GA tag on origin; pass --version for the first release" + version=$(next_version "$kind" "$last") +fi +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version must be X.Y.Z (got '$version')" +IFS=. read -r x y z <<<"$version" + +if [ "$kind" = patch ]; then + [ "$z" -gt 0 ] || fail "a patch needs Z > 0 (got $version)" + parent="v$x.$y.$((z - 1))" + [ "$parent" = "$last" ] || + fail "$version would patch $parent, but the newest GA is $last. Only the newest GA can be patched; published versions must stay in linear order." +fi +if [ -n "$last" ] && [ "$(printf '%s\n%s\n' "${last#v}" "$version" | sort -V | tail -1)" != "$version" ]; then + fail "version $version does not sort above the newest GA $last" +fi +[ "v$version" != "$last" ] || fail "version $version is already GA" + +branch="release-$version" +tag="v$version-rc1" +if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + fail "branch $branch already exists on origin" +fi +if [ -n "$(git ls-remote --tags origin "refs/tags/v$version" "refs/tags/v$version-rc*")" ]; then + fail "tags for v$version already exist on origin" +fi + +case $kind in +patch) + note "$branch starts at $last; fixes arrive as backports from main" + ;; +*) + note "commits on main since ${last:-} (informational)" + if [ -n "$last" ]; then + summarize_commits "$last..$sha" "$kind" + else + summarize_commits "$sha" "$kind" + fi + ;; +esac + +note "creating $branch and $tag at ${sha:0:12} ($kind from $from)" +git branch --no-track "$branch" "$sha" +git tag -a "$tag" "$sha" -m "$version release candidate 1" +git push origin "refs/heads/$branch" "refs/tags/$tag" + +note "creating label 'backport $branch'" +gh label create "backport $branch" --color 0E8A16 \ + --description "Cherry-pick this merged fix into $branch (next rc)" --force + +cat <>"$GITHUB_OUTPUT" +fi diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..333c49a --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Shared helpers for the release scripts. Source, do not execute. +# +# Conventions the scripts rely on: +# - Release branches are named release-X.Y.Z. A minor or major is cut from +# main; a patch is cut from the tag of the newest GA. +# - Release candidates are tagged vX.Y.Z-rcN, GA is tagged vX.Y.Z, both on +# the release branch only. main never carries a version tag. +# - The remote is called origin, and the scripts run inside a checkout of the +# repository being released (the caller repository in CI). + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +note() { echo "== $*"; } + +# Newest GA tag (vX.Y.Z, no -rc suffix) known locally. Empty if none. +# grep exits 1 on no match, which pipefail would turn into a failure of the +# whole assignment in the caller; a repository without a GA yet is normal. +last_ga_tag() { + git tag -l 'v[0-9]*' | { grep -Ev -- '-rc[0-9]+$' || true; } | sort -V | tail -1 +} + +# Newest GA tag strictly lower than the given vX.Y.Z. Empty if none. +prev_ga_tag() { + local cur=$1 + { + git tag -l 'v[0-9]*' | { grep -Ev -- '-rc[0-9]+$' || true; } + echo "$cur" + } | sort -Vu | awk -v cur="$cur" '$0 == cur { print prev; exit } { prev = $0 }' +} + +# Highest rcN tag for version X.Y.Z (argument without the v). Empty if none. +last_rc_tag() { + git tag -l "v$1-rc[0-9]*" | sort -V | tail -1 +} + +# Version a cut of the given kind produces, relative to the newest GA tag. +# minor -> X.(Y+1).0 patch -> X.Y.(Z+1) major -> (X+1).0.0 +next_version() { # + local kind=$1 base=${2#v} x y z + IFS=. read -r x y z <<<"$base" + case $kind in + major) echo "$((x + 1)).0.0" ;; + minor) echo "$x.$((y + 1)).0" ;; + patch) echo "$x.$y.$((z + 1))" ;; + *) return 1 ;; + esac +} + +# owner/name of the repository: GITHUB_REPOSITORY in CI, gh's view of origin +# when run by hand. Empty if neither is available. +repo_slug() { + if [ -n "${GITHUB_REPOSITORY:-}" ]; then + echo "$GITHUB_REPOSITORY" + else + gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true + fi +} + +# State of one context in a commit's combined status: success, failure, error, +# pending, or "none" when the context was never reported. Needs gh + GH_TOKEN. +commit_status_state() { # + local repo state + repo=$(repo_slug) + [ -n "$repo" ] || { echo none; return; } + state=$(gh api "repos/$repo/commits/$1/status" 2>/dev/null | + jq -r --arg c "$2" '[.statuses[] | select(.context == $c) | .state][0] // "none"' 2>/dev/null) || + state=none + echo "${state:-none}" +} + +# Tally Conventional Commit subjects in a revision range. With a cut kind, warn +# when the tally does not match what that kind is supposed to carry. The kind +# decides the number; this is informational only. +summarize_commits() { # [] + local range=$1 kind=${2:-} + local breaking=0 feat=0 fix=0 other=0 subj + # Regexes live in variables: bash cannot parse parentheses inside [[ =~ ]]. + local re_breaking='^[a-z]+(\([^)]*\))?!:' re_feat='^feat(\([^)]*\))?:' re_fix='^fix(\([^)]*\))?:' + while IFS= read -r subj; do + if [[ $subj =~ $re_breaking ]]; then + breaking=$((breaking + 1)) + elif [[ $subj =~ $re_feat ]]; then + feat=$((feat + 1)) + elif [[ $subj =~ $re_fix ]]; then + fix=$((fix + 1)) + else + other=$((other + 1)) + fi + done < <(git log --format=%s "$range") + echo " breaking: $breaking feat: $feat fix: $fix other: $other" + + case $kind in + minor) + if [ "$breaking" -eq 0 ] && [ "$feat" -eq 0 ]; then + echo " note: no feat or breaking commits since the last GA; a cut from main is still a minor" + fi + ;; + patch) + if [ "$breaking" -gt 0 ] || [ "$feat" -gt 0 ]; then + echo " warn: a patch carries fixes only, but the range contains feat or breaking commits" + fi + ;; + esac +} diff --git a/scripts/promote-images.sh b/scripts/promote-images.sh new file mode 100755 index 0000000..f376163 --- /dev/null +++ b/scripts/promote-images.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Promote the newest rc's images to the GA registry by digest, without a rebuild. +# +# scripts/promote-images.sh ... +# +# For every image: copy /: to +# /: with regctl, verify the digest did not change, +# and when is the highest GA tag known locally also tag it latest. A +# patch of an older minor (0.4.1 after 0.5.0 shipped) must not move latest back. +# +# Requires regctl logged in to both registries and the repository's tags +# fetched. Writes latest= and a multi-line digests block +# (=sha256:...) to $GITHUB_OUTPUT when set. + +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage="usage: promote-images.sh ..." +last_rc=${1:?$usage} +ga_tag=${2:?$usage} +src=${3:?$usage} +dst=${4:?$usage} +shift 4 +[ $# -gt 0 ] || fail "$usage" + +highest=$(last_ga_tag) +move_latest=false +if [ "$highest" = "$ga_tag" ]; then + move_latest=true +else + note "$ga_tag is below the highest GA $highest; latest stays" +fi + +digests="" +for img in "$@"; do + from="$src/$img:$last_rc" + to="$dst/$img:$ga_tag" + regctl image copy "$from" "$to" + s=$(regctl image digest "$from") + d=$(regctl image digest "$to") + [ "$s" = "$d" ] || fail "digest changed while copying $img: $s -> $d" + if [ "$move_latest" = true ]; then + regctl image copy "$to" "$dst/$img:latest" + fi + note "$img: $to@$d (= $from)" + digests="$digests$img=$d"$'\n' +done + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "latest=$move_latest" + echo "digests<>"$GITHUB_OUTPUT" +fi diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..d3b95de --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Generate the release notes draft for a GA (or rc) tag. +# +# scripts/release-notes.sh [] [] +# +# The range is ... Because release branches carry +# cherry-picks of main commits, the raw log could list a fix twice; two kinds +# of commits are dropped: +# - a backport (a commit with a "(cherry picked from commit )" trailer) +# whose origin is itself in the range: the origin is listed instead. On a +# patch branch the origin is NOT in the range (main is not an ancestor past +# the cut), so the backport stays and is the only record of the fix. +# - a main commit whose sha appears as a trailer in the previous GA's own +# backports: that fix already shipped in the previous release. +# Commits are grouped by Conventional Commit type. If RELEASE_NOTES_EXTRA names +# a file, its content is appended after Known Issues (repositories that pin +# other images, like the operator, put their component table there). + +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +tag=${1:?usage: release-notes.sh [] []} +prev=${2:-} +out=${3:-release_notes.md} +repo=$(repo_slug) +[ -n "$repo" ] || fail "cannot determine owner/repo: set GITHUB_REPOSITORY or authenticate gh" + +trailer_shas() { # -> origin shas named in cherry-pick trailers + git log --format=%B "$1" | sed -n 's/^(cherry picked from commit \([0-9a-f]\{40\}\))$/\1/p' | sort -u +} + +if [ -n "$prev" ]; then + range="$prev..$tag" + base=$(git merge-base "$prev" "$tag") + shipped=$(trailer_shas "$base..$prev") +else + range=$tag + shipped="" +fi + +in_range=$(git rev-list --no-merges "$range") +commits=$(mktemp) +trap 'rm -f "$commits"' EXIT +for c in $in_range; do + origins=$(git log -1 --format=%B "$c" | sed -n 's/^(cherry picked from commit \([0-9a-f]\{40\}\))$/\1/p') + listed_via_origin=0 + for o in $origins; do + grep -qx -- "$o" <<<"$in_range" && listed_via_origin=1 + done + [ "$listed_via_origin" = 1 ] && continue # a backport whose origin is listed + grep -qx -- "$c" <<<"$shipped" && continue # already shipped via the previous GA + git log -1 --format="%s ([%h](https://github.com/$repo/commit/%H))" "$c" | + sed -E "s|\(#([0-9]+)\)|([#\1](https://github.com/$repo/pull/\1))|g" >>"$commits" +done + +get() { grep -E "^$1(\(.*\))?!?:" "$commits" || true; } +bullets() { sed 's/^/- /'; } +breaking=$(grep -E '^[a-z]+(\(.*\))?!:' "$commits" || true) +feats=$(get feat) +fixes=$(get fix) +improvements=$(for t in docs refactor perf style build chore test ci; do get "$t"; done) + +{ + echo "# Release Notes" + echo + if [ -n "$breaking" ]; then + echo "## ⚠ Breaking Changes" + bullets <<<"$breaking" + echo + echo "> Review the documentation at this tag ([$tag](https://github.com/$repo/tree/$tag)) before upgrading." + echo + fi + echo "## New Features" + if [ -n "$feats" ]; then bullets <<<"$feats"; else echo "- None"; fi + echo + echo "## Improvements" + if [ -n "$improvements" ]; then bullets <<<"$improvements"; else echo "- None"; fi + echo + echo "## Fixed Issues" + if [ -n "$fixes" ]; then bullets <<<"$fixes"; else echo "- None"; fi + echo + echo "## Known Issues" + echo "- TBD" + echo + if [ -n "${RELEASE_NOTES_EXTRA:-}" ] && [ -f "$RELEASE_NOTES_EXTRA" ]; then + cat "$RELEASE_NOTES_EXTRA" + echo + fi + if [ -n "$prev" ]; then + echo "_Changes since $prev. Generated for $tag._" + else + echo "_Generated for $tag._" + fi +} >"$out" + +echo "wrote $out ($(grep -c '^- ' "$out") bullet(s), range ${prev:-}..$tag)" diff --git a/scripts/release-policy.sh b/scripts/release-policy.sh new file mode 100755 index 0000000..9be3deb --- /dev/null +++ b/scripts/release-policy.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Policy checks for pull requests whose base is a release-* branch. +# +# Everything on a release branch must be a fix that already lives on main +# (trunk-first), carried over unchanged. This script proves that mechanically. +# +# # check fails when effect +# 1 title type type not in fix|revert|docs|test|ci|build| block +# chore, or a breaking "!" marker +# 2 no merge commits base..head contains a merge block +# 3 cherry-pick trailer a commit lacks "(cherry picked from commit block (skipped with release-only) +# )" +# 4 origin is on main trailer sha unknown or not an ancestor of block (skipped with release-only) +# origin/main +# 5 content equals origin git patch-id differs from the origin commit block unless labelled backport-manual +# 6 release-only rationale release-only label without a "why not main" block +# section in the PR body +# 7 size >SIZE_LIMIT changed lines outside comment only +# GENERATED_PATHS +# +# Inputs (environment): BASE_REF PR_NUMBER PR_TITLE PR_BODY, PR_LABELS (newline +# separated) or PR_LABELS_JSON (JSON array), GENERATED_PATHS (pathspecs excluded +# from the size count, space separated, default "vendor go.sum *zz_generated*"), +# SIZE_LIMIT (default 400), GITHUB_REPOSITORY GH_TOKEN (for the size comment). +# HEAD is the PR head checkout. + +set -euo pipefail + +: "${BASE_REF:?}" "${PR_NUMBER:?}" "${PR_TITLE:?}" +PR_BODY=${PR_BODY:-} +PR_LABELS=${PR_LABELS:-} +if [ -n "${PR_LABELS_JSON:-}" ]; then # workflow passes labels as a JSON array + PR_LABELS=$(jq -r '.[]' <<<"$PR_LABELS_JSON") +fi +SIZE_LIMIT=${SIZE_LIMIT:-400} +ALLOWED_TYPES='fix|revert|docs|test|ci|build|chore' +# read does not glob, so "*zz_generated*" survives as a pathspec pattern. +read -r -a generated <<<"${GENERATED_PATHS:-vendor go.sum *zz_generated*}" +excludes=() +for p in ${generated[@]+"${generated[@]}"}; do excludes+=(":!$p"); done + +failures=() +problem() { + failures+=("$*") + echo "FAIL $*" +} +pass() { echo "ok $*"; } +has_label() { grep -qx -- "$1" <<<"$PR_LABELS"; } + +git fetch --quiet origin main "refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" +base="origin/$BASE_REF" + +# 1. title type (regexes in variables: bash cannot parse parentheses inside [[ =~ ]]) +re_allowed="^($ALLOWED_TYPES)(\([^)]+\))?!?: " +re_breaking='^[a-z]+(\([^)]+\))?!:' +if [[ $PR_TITLE =~ $re_allowed ]]; then + if [[ $PR_TITLE =~ $re_breaking ]]; then + problem "title: breaking change marker '!' is not allowed on a release branch" + else + pass "title type allowed" + fi +else + problem "title: type must be one of ${ALLOWED_TYPES//|/, } (got '${PR_TITLE%%:*}')" +fi + +# 2. no merge commits +if [ -n "$(git rev-list --merges "$base..HEAD")" ]; then + problem "merge commits are not allowed (squash-only history)" +else + pass "no merge commits" +fi + +# 3-5. provenance +commits=$(git rev-list --reverse "$base..HEAD") +if has_label release-only; then + echo "skip provenance checks (release-only)" +else + for c in $commits; do + short=${c:0:12} + origins=$(git log -1 --format=%B "$c" | + sed -n 's/^(cherry picked from commit \([0-9a-f]\{40\}\))$/\1/p') + if [ -z "$origins" ]; then + problem "$short: no '(cherry picked from commit )' trailer. Cherry-pick with -x, or label release-only with a rationale." + continue + fi + for o in $origins; do + if ! git cat-file -e "$o^{commit}" 2>/dev/null; then + problem "$short: origin $o is not a commit in this repository" + continue + fi + if ! git merge-base --is-ancestor "$o" origin/main; then + problem "$short: origin ${o:0:12} is not on main (trunk-first: land it on main first)" + continue + fi + pid_c=$(git show "$c" | git patch-id --stable | cut -d' ' -f1) + pid_o=$(git show "$o" | git patch-id --stable | cut -d' ' -f1) + if [ "$pid_c" = "$pid_o" ]; then + pass "$short: identical to main commit ${o:0:12}" + elif has_label backport-manual; then + pass "$short: differs from ${o:0:12} (accepted: backport-manual)" + else + problem "$short: content differs from origin ${o:0:12}. If you resolved a conflict by hand, add the backport-manual label." + fi + done + done +fi + +# 6. release-only rationale +if has_label release-only; then + if grep -qiE 'why not main|main에 (왜 )?불필요' <<<"$PR_BODY"; then + pass "release-only: rationale present" + else + problem "release-only: PR body must contain a 'Why not main' section explaining why this change has no counterpart on main" + fi +fi + +# 7. size (advisory) +changed=$(git diff --numstat "$base...HEAD" -- . ${excludes[@]+"${excludes[@]}"} | + awk '{ a += ($1 == "-" ? 0 : $1); d += ($2 == "-" ? 0 : $2) } END { print a + d + 0 }') +if [ "$changed" -gt "$SIZE_LIMIT" ]; then + echo "warn $changed changed lines outside generated paths (limit $SIZE_LIMIT)" + marker='' + if [ -n "${GH_TOKEN:-}" ] && ! gh api "repos/${GITHUB_REPOSITORY:-}/issues/$PR_NUMBER/comments" --jq '.[].body' 2>/dev/null | grep -q "$marker"; then + gh pr comment "$PR_NUMBER" --body "$marker +**release-policy**: this PR changes $changed lines outside generated paths (advisory limit $SIZE_LIMIT). Large backports raise the risk of a late regression; make sure this is a fix, not a feature riding along." || true + fi +else + pass "size: $changed changed lines outside generated paths" +fi + +if [ ${#failures[@]} -gt 0 ]; then + echo + echo "release-policy: ${#failures[@]} problem(s)" + exit 1 +fi +echo +echo "release-policy: all checks passed" diff --git a/scripts/setup-labels.sh b/scripts/setup-labels.sh new file mode 100755 index 0000000..509a5aa --- /dev/null +++ b/scripts/setup-labels.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Create the static labels the release process uses. Idempotent. +# +# scripts/setup-labels.sh [] +# +# The per-release label "backport release-X.Y.Z" is not created here: the +# cut-release workflow creates it at cut and the GA workflow deletes it, which +# is what makes "no backports after GA" a structural property rather than a +# rule to remember. + +set -euo pipefail + +repo=${1:-${GITHUB_REPOSITORY:-}} +args=() +[ -n "$repo" ] && args=(--repo "$repo") + +label() { # + gh label create "$1" --color "$2" --description "$3" --force ${args[@]+"${args[@]}"} + echo "ok $1" +} + +# GitHub caps label descriptions at 100 characters. +label backport-manual 5319E7 "Hand-resolved cherry-pick; content may differ from the main commit (release-policy accepts)" +label release-only FBCA04 "No counterpart on main; PR body must say why. Release manager approval required" +label release-blocker B60205 "Candidate for the rc after the final-rc announcement; taking it delays GA. Priority only" +label tag-rc 0052CC "When merged into a release branch, tag-release tags the next rc. Put it on the last PR of a batch" diff --git a/scripts/slack-notify.sh b/scripts/slack-notify.sh new file mode 100755 index 0000000..d5b1b96 --- /dev/null +++ b/scripts/slack-notify.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Post a message to the release Slack channel. +# +# scripts/slack-notify.sh "" +# scripts/slack-notify.sh --color \ +# --title "" [--details ""] [--link "|