diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index d381734..8f6d17c 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -53,6 +53,13 @@ on: tag-prefix: type: string default: v + image-tag-prefix: + description: >- + Prefix of the image tag when it differs from the git tag's (a + repository with several release projects: git tag db-v1.2.3, image + tag v1.2.3). Empty = tag-prefix. + type: string + default: "" dockerfile: type: string default: Dockerfile @@ -62,7 +69,7 @@ on: build-args: description: >- Newline-separated KEY=VALUE build arguments; {tag} in a value becomes - the release tag (VERSION={tag} -> VERSION=v1.2.3; a dry run gets the + the image tag (VERSION={tag} -> VERSION=v1.2.3; a dry run gets the tag it would release). type: string default: "" @@ -111,6 +118,9 @@ on: value: ${{ jobs.publish.outputs.digest }} tag: value: ${{ jobs.verify.outputs.tag }} + image-tag: + description: The image's tag (image-tag-prefix + version). + value: ${{ jobs.verify.outputs.image-tag }} # No workflow-level permissions: every job names its own (contents: read, # id-token: write), so a release pull request's dry run starts with read rights. @@ -128,6 +138,7 @@ jobs: id-token: write outputs: tag: ${{ steps.version.outputs.tag }} + image-tag: ${{ steps.image.outputs.tag }} mode: ${{ steps.version.outputs.mode }} push: ${{ steps.version.outputs.push }} build: ${{ steps.registry.outputs.build || steps.version.outputs.build }} @@ -201,6 +212,13 @@ jobs: service_account: ${{ inputs.service-account }} token_format: access_token access_token_lifetime: 300s + - name: the image tag + id: image + env: + TAG: ${{ steps.version.outputs.tag }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + IMAGE_TAG_PREFIX: ${{ inputs.image-tag-prefix }} + run: echo "tag=${IMAGE_TAG_PREFIX:-$TAG_PREFIX}${TAG#"$TAG_PREFIX"}" >> "$GITHUB_OUTPUT" - name: the tag is not in the registry yet id: registry if: steps.version.outputs.push == 'true' @@ -208,7 +226,7 @@ jobs: TOKEN: ${{ steps.auth.outputs.access_token }} REGISTRY: ${{ steps.version.outputs.registry }} IMAGE: ${{ inputs.image }} - TAG: ${{ steps.version.outputs.tag }} + TAG: ${{ steps.image.outputs.tag }} MODE: ${{ steps.version.outputs.mode }} SHA: ${{ github.sha }} run: | @@ -275,7 +293,7 @@ jobs: BUILD_ARGS: ${{ inputs.build-args }} SOURCE_URL: ${{ github.server_url }}/${{ github.repository }} REVISION: ${{ github.sha }} - VERSION: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.image-tag }} ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} run: | args=(--platform "$PLATFORM" -f "$DOCKERFILE" @@ -339,13 +357,13 @@ jobs: service_account: ${{ inputs.service-account }} token_format: access_token access_token_lifetime: 600s - - name: multi-arch index ${{ needs.verify.outputs.tag }} + - name: multi-arch index ${{ needs.verify.outputs.image-tag }} id: index env: TOKEN: ${{ steps.auth.outputs.access_token }} REGISTRY: ${{ needs.verify.outputs.registry }} IMAGE: ${{ inputs.image }} - TAG: ${{ needs.verify.outputs.tag }} + TAG: ${{ needs.verify.outputs.image-tag }} LATEST: ${{ inputs.latest }} PLATFORMS: ${{ inputs.platforms }} run: | diff --git a/.github/workflows/release-intent.yml b/.github/workflows/release-intent.yml index 6950611..29f02c0 100644 --- a/.github/workflows/release-intent.yml +++ b/.github/workflows/release-intent.yml @@ -21,6 +21,14 @@ on: tag-prefix: type: string default: v + release-name: + description: >- + This project's name, in a repository with several release projects + (each with its own version file and tag prefix): a release pull + request of another project is then a no-release for this one. + Empty = the repository's only project. + type: string + default: "" harness-ref: description: Ref of IronFlock/workflows the harness scripts are taken from. type: string @@ -72,6 +80,7 @@ jobs: env: VERSION_FILE: ${{ inputs.version-file }} TAG_PREFIX: ${{ inputs.tag-prefix }} + RELEASE_NAME: ${{ inputs.release-name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} TITLE: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index e15e1af..fb8cbd5 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -283,6 +283,39 @@ jobs: artifact-prefix: package-json- harness-ref: ${{ github.sha }} + # A second release project in a repository: git tag db-v, image tag + # v. + release-dry-run-image-tag: + uses: ./.github/workflows/release-image.yml + permissions: + contents: read + id-token: write + with: + image: example.invalid/selftest/release + version-file: tests/fixtures/release/version.txt + tag-prefix: db-v + image-tag-prefix: v + dockerfile: tests/fixtures/release/Dockerfile + context: tests/fixtures/release + build-args: VERSION={tag} + workload-identity-provider: dry-run-unused + service-account: dry-run-unused + artifact-prefix: image-tag- + harness-ref: ${{ github.sha }} + + assert-image-tag: + needs: [release-dry-run-image-tag] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: git tag db-v0.0.1, image tag v0.0.1 + env: + TAG: ${{ needs.release-dry-run-image-tag.outputs.tag }} + IMAGE_TAG: ${{ needs.release-dry-run-image-tag.outputs.image-tag }} + run: | + echo "tag $TAG, image tag $IMAGE_TAG" + [ "$TAG" = db-v0.0.1 ] && [ "$IMAGE_TAG" = v0.0.1 ] + # A named build context from an artifact of the same run. build-context-artifact: runs-on: ubuntu-latest @@ -342,7 +375,7 @@ jobs: # above succeeded (skipped or cancelled counts as failed). selftest-ok: if: ${{ always() }} - needs: [lint-self, lint-templates, lint-planted, scripts, gate-pass, gate-fail, node-gate-npm, node-gate-npm-tools, node-gate-bun, node-gate-fail, python-gate-pass, python-gate-fail, security-clean, security-clean-node, security-planted, release-dry-run, release-dry-run-package-json, build-context-artifact, release-dry-run-build-context, assert-planted] + needs: [lint-self, lint-templates, lint-planted, scripts, gate-pass, gate-fail, node-gate-npm, node-gate-npm-tools, node-gate-bun, node-gate-fail, python-gate-pass, python-gate-fail, security-clean, security-clean-node, security-planted, release-dry-run, release-dry-run-package-json, build-context-artifact, release-dry-run-build-context, release-dry-run-image-tag, assert-image-tag, assert-planted] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/README.md b/README.md index ee8bcdb..d51c125 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,14 @@ the files a bump changes, and writes the version in `_write-version`; copy `release/v`. It then waits for the merge and for the release run, and ends back on `main` (detached on `origin/main` in a worktree). +A repository with several release projects (each its own version file, tag +prefix and release workflow, e.g. `db-v*` and `migrator-v*`): each project's +`release-intent` call gets its `release-name`, so a release pull request of +one project is a no-release for the others (and may not change their +versions); `pr.sh release` takes `TAG_PREFIX` and `WRITE_VERSION` (the +project's version recipe) from the environment, and `pr.sh merge` a +comma-separated list of every version file. One release per pull request. + Onboarding a repository onto this flow, besides the caller files: - `main` ruleset: squash merges only; repository setting "default commit diff --git a/scripts/pr.sh b/scripts/pr.sh index d0705a6..f7fdbeb 100755 --- a/scripts/pr.sh +++ b/scripts/pr.sh @@ -5,13 +5,14 @@ # script at IronFlock/workflows@v1 and runs it, so the flow is the same # everywhere (see "Pull requests from a laptop" in the README). # -# pr.sh merge [] +# pr.sh merge <version-file[,version-file...]|-> [<title>] # a no-release pull request from the current feature branch (pushed even # if origin does not have it yet), titled <title> (default: the subject # of the branch's first commit), merged automatically (squash) once the -# required checks pass. The version must equal main's (`-`: a repository -# without a version, whose main is the publication), and the title must -# not start with "release ": such a merge releases nothing. +# required checks pass. Every version must equal main's (several +# comma-separated files: a repository with several release projects; `-`: +# a repository without a version, whose main is the publication), and the +# title must not start with "release ": such a merge releases nothing. # # pr.sh release <patch|minor|major> <name> <version-file> <workflow> <file>... # a release pull request. The next version is computed from the version @@ -23,7 +24,9 @@ # required checks pass; its merge starts <workflow> (the image and the # tag), and this waits for that run. # -# Env: PR_WAIT_SECONDS (default 20) between polls. +# Env: PR_WAIT_SECONDS (default 20) between polls. For one of several release +# projects in a repository: TAG_PREFIX (default v; e.g. db-v) and +# WRITE_VERSION (default _write-version; the project's recipe). set -euo pipefail wait_s=${PR_WAIT_SECONDS:-20} @@ -115,8 +118,8 @@ leave_branch() { } cmd_merge() { - [ $# -ge 1 ] || die "usage: pr.sh merge <version-file|-> [<title>]" - local vf=$1 title=${2:-} branch here there + [ $# -ge 1 ] || die "usage: pr.sh merge <version-file[,version-file...]|-> [<title>]" + local vfs=$1 title=${2:-} branch here there vf branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = main ] || [ "$branch" = HEAD ]; then die "merge: run on a feature branch" @@ -126,11 +129,13 @@ cmd_merge() { git fetch --quiet origin main git merge-base --is-ancestor origin/main HEAD || die "merge: $branch does not contain origin/main (git rebase origin/main)" - if [ "$vf" != - ]; then - here=$(version "$vf") - there=$(version "$vf" origin/main) - [ "$here" = "$there" ] || - die "merge: $branch changes the version ($there -> $here): that is a release (just release-patch)" + if [ "$vfs" != - ]; then + for vf in ${vfs//,/ }; do + here=$(version "$vf") + there=$(version "$vf" origin/main) + [ "$here" = "$there" ] || + die "merge: $branch changes $vf ($there -> $here): that is a release (just release-patch)" + done fi [ -n "$title" ] || title=$(git log --reverse --format=%s origin/main..HEAD | head -n1) [ -n "$title" ] || die "merge: no commits on $branch beyond main" @@ -165,11 +170,11 @@ cmd_release() { base=$(version "$vf" origin/main) [ -n "$base" ] || die "release: $vf has no version on origin/main" next=$(next_version "$level" "$base") - tag="v$next" + tag="${TAG_PREFIX:-v}$next" title="release $name $tag" if [ "$(version "$vf")" != "$next" ]; then - just _write-version "$next" - [ "$(version "$vf")" = "$next" ] || die "release: just _write-version $next left $vf at $(version "$vf")" + just "${WRITE_VERSION:-_write-version}" "$next" + [ "$(version "$vf")" = "$next" ] || die "release: just ${WRITE_VERSION:-_write-version} $next left $vf at $(version "$vf")" fi echo "==> $title (main has $base)" diff --git a/scripts/release.sh b/scripts/release.sh index 77cd8b0..a0b2947 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -10,7 +10,8 @@ # # release.sh intent # the release intent of a pull request into main. Env: VERSION_FILE, -# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE. The version file decides: +# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE, RELEASE_NAME (optional: one of +# several release projects in the repository). The version file decides: # release the version rises; the title is `release <name> <tag>` # (just release-patch|minor|major) # no-release the version is unchanged (just merge); any title except @@ -72,6 +73,25 @@ intent() { head=$(version "$VERSION_FILE" "$HEAD_SHA") [ -n "$head" ] || { echo "::error::$VERSION_FILE has no version at $HEAD_SHA"; return 1; } + # Several release projects in one repository (RELEASE_NAME set per project): + # a release title that names another project is a no-release for this one, + # whose version must then stay as it is (one release per pull request). + local lower named="" + # shellcheck disable=SC2153 # TITLE comes from the environment (see the header) + lower=$(printf '%s' "$TITLE" | tr '[:upper:]' '[:lower:]') + if [ -n "${RELEASE_NAME:-}" ] && [[ "$lower" =~ ^release\ ([^\ ]+)\ ]]; then + named=${BASH_REMATCH[1]} + fi + if [ -n "$named" ] && [ "$named" != "$RELEASE_NAME" ]; then + if [ "$head" != "$base" ]; then + echo "::error::$VERSION_FILE changes ($base -> $head), but the pull request releases $named: one release per pull request" + return 1 + fi + echo "a release of $named: $RELEASE_NAME releases nothing" + out kind=no-release + return 0 + fi + if [ "$head" = "$base" ]; then # The squash merge carries this title, and release.yml starts on a merge # whose message starts with 'release ' (GitHub compares case-insensitively). @@ -120,12 +140,16 @@ mode() { # The squash merge of a release pull request: `release <name> <tag> (#n)`. title=$(head -n1 <<<"$COMMIT_TITLE") title=${title% (#*)} - if ! [[ "$title" =~ ^release\ [^\ ]+\ (.+)$ ]]; then + if ! [[ "$title" =~ ^release\ ([^\ ]+)\ (.+)$ ]]; then echo "::error::'$title' is not the merge of a release pull request: nothing to release" return 1 fi - if [ "${BASH_REMATCH[1]}" != "$tag" ]; then - echo "::error::the merged release pull request names ${BASH_REMATCH[1]}, but $VERSION_FILE holds $tag" + if [ -n "${RELEASE_NAME:-}" ] && [ "${BASH_REMATCH[1]}" != "$RELEASE_NAME" ]; then + echo "::error::'$title' releases ${BASH_REMATCH[1]}, not $RELEASE_NAME" + return 1 + fi + if [ "${BASH_REMATCH[2]}" != "$tag" ]; then + echo "::error::the merged release pull request names ${BASH_REMATCH[2]}, but $VERSION_FILE holds $tag" return 1 fi if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then diff --git a/scripts/test_pr.sh b/scripts/test_pr.sh index 4a1d2d0..ac67843 100755 --- a/scripts/test_pr.sh +++ b/scripts/test_pr.sh @@ -38,7 +38,7 @@ EOF cat >"$work/bin/just" <<'EOF' #!/usr/bin/env bash echo "just $*" >>"$STUB/log" -[ "$1" = _write-version ] || exit 1 +case "$1" in _write-*) ;; *) exit 1 ;; esac vf=$(cat "$STUB/vf") if [ "$(basename "$vf")" = package.json ]; then jq --arg v "$2" '.version = $v' "$vf" >"$vf.tmp" && mv "$vf.tmp" "$vf" @@ -239,5 +239,31 @@ check "release: pyproject.toml" 0 release patch x pyproject.toml release.yml pyp expect "release: [project] version at 1.10.1" grep -qx 'version = "1.10.1"' <(git -C "$origin" show feat:pyproject.toml) expect "release: other tables untouched" grep -qx 'version = "9.9.9"' <(git -C "$origin" show feat:pyproject.toml) +# Several release projects in one repository. +setup VERSION 1.2.3 +mkdir -p "$repo/migrator" && echo 0.3.5 >"$repo/migrator/VERSION" +git -C "$repo" add -A && git -C "$repo" commit -q -m "a second project" +git -C "$repo" push -q origin feat +git -C "$repo" switch -q main && git -C "$repo" merge -q --ff-only feat && git -C "$repo" push -q origin main && git -C "$repo" switch -q feat +echo "change" >>"$repo/work.txt" && git -C "$repo" commit -q -am "more work" +check "merge: several version files, none changed" 0 merge VERSION,migrator/VERSION +echo 0.3.6 >"$repo/migrator/VERSION" && git -C "$repo" commit -q -am "bump the second project by hand" +check "merge: refused when the second version file changes" 1 merge VERSION,migrator/VERSION + +setup VERSION 1.2.3 +ci_tag db-v1.2.4 +export TAG_PREFIX=db-v +check "release: TAG_PREFIX names the tag" 0 release patch platform-db VERSION release-db.yml VERSION +unset TAG_PREFIX +expect "release: title with the prefixed tag" logged "gh pr create --base main --head feat --title release platform-db db-v1.2.4" +expect "release: the prefixed tag fetched" git -C "$repo" rev-parse --verify refs/tags/db-v1.2.4 + +setup VERSION 1.2.3 +ci_tag v1.2.4 +export WRITE_VERSION=_write-db-version +check "release: WRITE_VERSION names the project's recipe" 0 release patch x VERSION release.yml VERSION +unset WRITE_VERSION +expect "release: the project's recipe was called" logged "just _write-db-version 1.2.4" + [ "$fail" = 0 ] && echo "all pr.sh tests passed" exit "$fail" diff --git a/scripts/test_release.sh b/scripts/test_release.sh index 3ae1dde..6a94333 100755 --- a/scripts/test_release.sh +++ b/scripts/test_release.sh @@ -97,6 +97,27 @@ for vf in VERSION package.json pyproject.toml; do check "$vf: 1.2.3 -> 1.10.0 is higher (version sort)" 0 intent "$r" "$vf" "$base" "$minor" "release x v1.10.0" done +# Several release projects in one repository: RELEASE_NAME per project. +intent_named() { # intent_named <dir> <file> <base> <head> <title> <name> + (cd "$1" && VERSION_FILE=$2 TAG_PREFIX=db-v BASE_SHA=$3 HEAD_SHA=$4 TITLE=$5 RELEASE_NAME=$6 "$script" intent) +} +r="$work/intent-named" +repo "$r" VERSION 1.2.3 +base=$(git -C "$r" rev-parse HEAD) +same=$(commit "$r" "docs") +export GITHUB_OUTPUT="$work/out-named" +: > "$GITHUB_OUTPUT" +check "named: another project's release is a no-release here" 0 intent_named "$r" VERSION "$base" "$same" "release platform-db-migrator migrator-v0.3.36" platform-db +expect_output "named: another project's release: kind no-release" "kind=no-release" +unset GITHUB_OUTPUT +check "named: this project's title without a bump" 1 intent_named "$r" VERSION "$base" "$same" "release platform-db db-v1.2.3" platform-db +git -C "$r" checkout -q "$base" +setv "$r" VERSION 1.2.4 +bump=$(commit "$r" "bump") +check "named: this project's release PR" 0 intent_named "$r" VERSION "$base" "$bump" "release platform-db db-v1.2.4" platform-db +check "named: a bump under another project's release title" 1 intent_named "$r" VERSION "$base" "$bump" "release platform-db-migrator migrator-v0.3.36" platform-db +check "named: the prefix of this project's tag is required" 1 intent_named "$r" VERSION "$base" "$bump" "release platform-db v1.2.4" platform-db + mode() { # mode <dir> <event> <ref> <ref type> <ref name> <commit title> (cd "$1" && VERSION_FILE=VERSION TAG_PREFIX=v EVENT=$2 REF=$3 REF_TYPE=$4 REF_NAME=$5 \ RELEASE_BRANCH=main COMMIT_TITLE=$6 SHA=$(git rev-parse HEAD) "$script" mode) @@ -113,6 +134,11 @@ expect_output "merge of a release PR: tag" "tag=v2.0.0" check "merge title with the PR body below" 0 mode "$r" push refs/heads/main branch main $'release x v2.0.0 (#12)\n\nbody' check "merge of a no-release PR is refused" 1 mode "$r" push refs/heads/main branch main "docs: typo (#13)" check "merge naming another version is refused" 1 mode "$r" push refs/heads/main branch main "release x v1.9.0 (#14)" +check "merge of a release PR: any name without RELEASE_NAME" 0 mode "$r" push refs/heads/main branch main "release y v2.0.0 (#12)" +named_mode() { (cd "$r" && VERSION_FILE=VERSION TAG_PREFIX=v EVENT=push REF=refs/heads/main REF_TYPE=branch REF_NAME=main \ + RELEASE_BRANCH=main COMMIT_TITLE=$1 SHA=$(git rev-parse HEAD) RELEASE_NAME=x "$script" mode); } +check "merge of another project's release PR is refused with RELEASE_NAME" 1 named_mode "release y v2.0.0 (#12)" +check "merge of this project's release PR with RELEASE_NAME" 0 named_mode "release x v2.0.0 (#12)" git -C "$r" tag -a v2.0.0 -m "release x v2.0.0" : > "$GITHUB_OUTPUT" check "a re-run after a complete release passes" 0 mode "$r" push refs/heads/main branch main "release x v2.0.0 (#12)"