From d9db979e214abcc72bd8110ef65b52ab7d7c4d30 Mon Sep 17 00:00:00 2001 From: rebel-mskim Date: Fri, 11 Sep 2026 19:16:49 +0900 Subject: [PATCH 1/2] feat: release kit v1 Reusable GitHub Actions workflows (workflow_call) and scripts that give every RBLN-SW cloud component the same release process: - cut-release: release-X.Y.Z branch + vX.Y.Z-rc1 (minor/patch/major) - tag-release: next rc, or GA on the newest rc commit (rc-validation gate) - backport: label-driven cherry-pick PRs with provenance trailer - release-policy: required check for PRs into release-* branches - release: rc -> staging registry (multi-arch, optional Helm chart, pre-release); GA -> promote by digest to the public registry, latest for the highest GA, draft release notes, backport label cleanup, Slack summaries Scripts are bash 3.2 compatible and covered by 51 bats tests; workflows pass actionlint; caller stubs live in templates/workflows/ and pin @v1. All variables the kit reads carry the CLOUD_COMPONENT_ prefix. Co-Authored-By: Claude Fable 5.1 --- .github/actionlint.yaml | 11 + .github/workflows/backport.yaml | 141 +++++++ .github/workflows/ci.yaml | 49 +++ .github/workflows/cut-release.yaml | 143 +++++++ .github/workflows/release-policy.yaml | 57 +++ .github/workflows/release.yaml | 471 ++++++++++++++++++++++++ .github/workflows/tag-release.yaml | 146 ++++++++ README.md | 36 +- rulesets/protect-main.json | 32 ++ rulesets/release-branches.json | 44 +++ rulesets/release-tags.json | 17 + scripts/apply-rulesets.sh | 101 +++++ scripts/classify-tag.sh | 61 +++ scripts/cut-release.sh | 114 ++++++ scripts/lib.sh | 108 ++++++ scripts/promote-images.sh | 57 +++ scripts/release-notes.sh | 97 +++++ scripts/release-policy.sh | 137 +++++++ scripts/setup-labels.sh | 26 ++ scripts/slack-notify.sh | 94 +++++ scripts/tag-release.sh | 80 ++++ templates/workflows/backport.yaml | 23 ++ templates/workflows/cut-release.yaml | 35 ++ templates/workflows/release-policy.yaml | 26 ++ templates/workflows/release.yaml | 31 ++ templates/workflows/tag-release.yaml | 38 ++ tests/apply-rulesets.bats | 38 ++ tests/classify-tag.bats | 74 ++++ tests/cut-release.bats | 75 ++++ tests/helpers.bash | 79 ++++ tests/lib.bats | 50 +++ tests/promote-images.bats | 50 +++ tests/release-notes.bats | 71 ++++ tests/release-policy.bats | 98 +++++ tests/setup-labels.bats | 18 + tests/slack-notify.bats | 38 ++ tests/tag-release.bats | 81 ++++ 37 files changed, 2846 insertions(+), 1 deletion(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/backport.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/cut-release.yaml create mode 100644 .github/workflows/release-policy.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/tag-release.yaml create mode 100644 rulesets/protect-main.json create mode 100644 rulesets/release-branches.json create mode 100644 rulesets/release-tags.json create mode 100755 scripts/apply-rulesets.sh create mode 100755 scripts/classify-tag.sh create mode 100755 scripts/cut-release.sh create mode 100644 scripts/lib.sh create mode 100755 scripts/promote-images.sh create mode 100755 scripts/release-notes.sh create mode 100755 scripts/release-policy.sh create mode 100755 scripts/setup-labels.sh create mode 100755 scripts/slack-notify.sh create mode 100755 scripts/tag-release.sh create mode 100644 templates/workflows/backport.yaml create mode 100644 templates/workflows/cut-release.yaml create mode 100644 templates/workflows/release-policy.yaml create mode 100644 templates/workflows/release.yaml create mode 100644 templates/workflows/tag-release.yaml create mode 100644 tests/apply-rulesets.bats create mode 100644 tests/classify-tag.bats create mode 100644 tests/cut-release.bats create mode 100644 tests/helpers.bash create mode 100644 tests/lib.bats create mode 100644 tests/promote-images.bats create mode 100644 tests/release-notes.bats create mode 100644 tests/release-policy.bats create mode 100644 tests/setup-labels.bats create mode 100644 tests/slack-notify.bats create mode 100644 tests/tag-release.bats 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..b7971d7 --- /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 +[ -n "$team" ] && [ -n "$app_id" ] || { echo "$usage" >&2; exit 2; } +[ -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 "|