diff --git a/.github/workflows/build-test-distribute.yml b/.github/workflows/build-test-distribute.yml index fd610c4b96d7..e01dbb9772fa 100644 --- a/.github/workflows/build-test-distribute.yml +++ b/.github/workflows/build-test-distribute.yml @@ -2,11 +2,11 @@ name: Build Test Distribute # Controls when the action will run. on: - # Triggers the workflow on push or pull request events but only for the master branch + # Triggers the workflow on push or pull request events for master and release branches push: - branches: [ master ] + branches: [ master, 'release/**' ] pull_request: - branches: [ master ] + branches: [ master, 'release/**' ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 5caae32a9d85..61cd1d8f5215 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -55,10 +55,10 @@ on: value: ${{ github.event_name == 'schedule' || jobs.prepare-config.outputs.tag-full-ci == 'true' || jobs.prepare-config.outputs.tag-build-release-windows == 'true' }} test_pip_build: description: - value: ${{ jobs.prepare-config.outputs.tag-test-pip-build == 'true' }} + value: ${{ jobs.prepare-config.outputs.tag-test-pip-build == 'true' || ( github.event_name == 'push' && startsWith(github.ref_name, 'release/') ) }} build-release-win: - description: "True when full-ci, scheduled, or tag-build-release-windows is set" - value: ${{ github.event_name == 'schedule' || jobs.prepare-config.outputs.tag-full-ci == 'true' || jobs.prepare-config.outputs.tag-build-release-windows == 'true' }} + description: "True when full-ci, scheduled, tag-build-release-windows is set, or on a release branch" + value: ${{ github.event_name == 'schedule' || jobs.prepare-config.outputs.tag-full-ci == 'true' || jobs.prepare-config.outputs.tag-build-release-windows == 'true' || ( github.event_name == 'push' && startsWith(github.ref_name, 'release/') ) }} # flags for disabling builds for different systems build_enable_ubuntu_arm64: description: @@ -230,9 +230,13 @@ jobs: - name: Select Docker image tag id: select-docker-image-tag run: | - if [ "${{ github.event_name }}" = "pull_request" ] && [ "${CAN_PUSH_DOCKER_TAGS}" = "true" ]; then + # Only master writes `latest`: a release branch's docker sources freeze + # while master moves on, so it would point master at an older image. + if { [ "${{ github.event_name }}" = "pull_request" ] || \ + [ "${{ startsWith(github.ref_name, 'release/') }}" = "true" ] ; } && \ + [ "${CAN_PUSH_DOCKER_TAGS}" = "true" ]; then # https://stackoverflow.com/q/58033366/7325599 - IMAGE_TAG=$(echo "${{ github.head_ref }}" | sed -r 's/[^a-zA-Z0-9._-]+/-/g') + IMAGE_TAG=$(echo "${{ github.head_ref || github.ref_name }}" | sed -r 's/[^a-zA-Z0-9._-]+/-/g') else IMAGE_TAG="latest" fi @@ -247,8 +251,10 @@ jobs: - name: Select vcpkg Docker image tag id: select-vcpkg-docker-image-tag run: | - if [ "${{ github.event_name }}" = "pull_request" ] && [ "${CAN_PUSH_DOCKER_TAGS}" = "true" ]; then - IMAGE_TAG=$(echo "${{ github.head_ref }}" | sed -r 's/[^a-zA-Z0-9._-]+/-/g') + if { [ "${{ github.event_name }}" = "pull_request" ] || \ + [ "${{ startsWith(github.ref_name, 'release/') }}" = "true" ] ; } && \ + [ "${CAN_PUSH_DOCKER_TAGS}" = "true" ]; then + IMAGE_TAG=$(echo "${{ github.head_ref || github.ref_name }}" | sed -r 's/[^a-zA-Z0-9._-]+/-/g') else IMAGE_TAG="latest" fi @@ -283,7 +289,16 @@ jobs: id: version-tag run: | version=${{ steps.version.outputs.version }} - echo "release_tag=${version}" >> $GITHUB_OUTPUT + # Both lines version off the same baseline, so the same vA.B.C.N comes up + # on each; -rc keeps their tags apart. Publishing drops it. + if [ "${{ github.event_name }}" = "push" ] && \ + [ "${{ startsWith(github.ref_name, 'release/') }}" = "true" ] ; then + # %%-* first, so a pr-test namespace can't yield -pr-test-rc + release_tag="${version%%-*}-rc" + else + release_tag="${version}" + fi + echo "release_tag=${release_tag}" >> $GITHUB_OUTPUT echo "app_version=${version%%-*}" >> $GITHUB_OUTPUT - name: Set matrix for ubuntu-x64 builds diff --git a/.github/workflows/pip-build.yml b/.github/workflows/pip-build.yml index ee51e935edfb..c9fd858912f5 100644 --- a/.github/workflows/pip-build.yml +++ b/.github/workflows/pip-build.yml @@ -1,19 +1,16 @@ name: Build Pip Wheels # When ran from the github UI, we build the target commit exactly. -# When ran on release, we use the latest tagged commit in the `master` branch. # This doesn't run directly on pull request, because it's impossible to disable the whole file # with a single condition (specific label present). Because of that, we conditionally run this from a separate `.yml` file. on: - release: - types: [ published ] # This is the manual run from the UI. workflow_dispatch: inputs: vcpkg_docker_image_tag: # Empty string means the same thing as "latest", but we use an empty string here to ensure this works, - # because `types: [published]` can't have any inputs, so there we are forced to use an empty string, so it needs to work. + # because a `workflow_call` from a release path can't pass inputs, so it needs to work. default: "" required: false type: string @@ -95,7 +92,7 @@ jobs: version_tag: ${{ steps.get-latest-tag.outputs.tag }} build-matrix: ${{ steps.build-matrix.outputs.matrix }} test-matrix: ${{ steps.test-matrix.outputs.matrix }} - publish_prod: ${{ github.event_name == 'release' || inputs.publish == 'publish-prod' }} + publish_prod: ${{ inputs.publish == 'publish-prod' }} publish_test: ${{ inputs.publish == 'publish-test' }} build_ref: ${{ inputs.ref || steps.get-latest-tag.outputs.tag }} release_tag: ${{ inputs.release_tag || steps.release-for-ref.outputs.tag }} @@ -107,7 +104,7 @@ jobs: # Disabling this with an `if:` results in `steps.get-latest-tag.outputs.tag` being empty. # Passing that to `ref:` makes it default to the current commit, which is exactly what we need. - uses: actions-ecosystem/action-get-latest-tag@v1 - if: ${{ github.event_name == 'release' || inputs.publish == 'publish-prod' }} + if: ${{ inputs.publish == 'publish-prod' }} id: get-latest-tag # Attach to a release that already exists for this ref; never create one. No match leaves the @@ -952,11 +949,6 @@ jobs: - name: Upload to TestPyPI run: twine upload --repository-url https://test.pypi.org/legacy/ ./meshlib*.whl -u __token__ -p ${{ secrets.TEST_PYPI_TOKEN }} --skip-existing - post-release-test: - if: ${{ needs.setup.outputs.publish_prod == 'true' }} - needs: [setup, upload-to-release] - uses: MeshInspector/MeshLib/.github/workflows/release-tests.yml@master - delete-artifacts: timeout-minutes: 5 runs-on: ubuntu-latest diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 000000000000..bd8285866908 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,48 @@ +name: Publish documentation + +on: + release: + types: [ released ] + workflow_dispatch: + inputs: + release_tag: + description: Tag of the release to publish the documentation of + required: true + type: string + +permissions: + contents: read + actions: read # list workflow runs, and download artifacts from another run + +jobs: + # The binding artifacts live on the build run, not on the release, and + # `download-artifact` only reaches another run by id. + find-build-run: + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + run_id: ${{ steps.find.outputs.run_id }} + steps: + - name: Find the build that produced this release + id: find + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + run: | + set -euo pipefail + sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${TAG}" --jq .sha) + run_id=$( + gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/build-test-distribute.yml/runs?head_sha=${sha}&status=success" \ + --jq '[ .workflow_runs[] | .id ] | max // empty' + ) + [ -n "${run_id}" ] || { echo "no successful build for ${TAG} (${sha})" >&2 ; exit 1 ; } + echo "${TAG} -> ${sha} -> run ${run_id}" + echo "run_id=${run_id}" >> $GITHUB_OUTPUT + + update-documentation: + needs: find-build-run + uses: ./.github/workflows/update-docs.yml + with: + output_folder: MeshLib + run_id: ${{ needs.find-build-run.outputs.run_id }} + secrets: inherit diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 000000000000..d937e4e94dc2 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,42 @@ +name: Publish to PyPI + +on: + release: + types: [ released ] + workflow_dispatch: + inputs: + release_tag: + description: Tag of the release whose wheels to publish + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + # The wheels attached to the release are the ones that were built and + # tested on that commit; nothing is rebuilt here. + - name: Download wheels from the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.release_tag || github.event.release.tag_name }} + run: | + gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" --pattern '*.whl' --clobber + ls -1 meshlib*.whl + + - name: Install twine + run: python3 -m pip install --upgrade pip twine packaging + + - name: Upload to Production PyPi + run: twine upload ./meshlib*.whl -u __token__ -p ${{ secrets.PYPI_MESHINSPECTOR_TOKEN }} --skip-existing + + post-release-test: + needs: publish + uses: ./.github/workflows/release-tests.yml + with: + version_tag: ${{ inputs.release_tag || github.event.release.tag_name }} + secrets: inherit diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000000..e1bf7226beb0 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,89 @@ +name: Publish release + +# Run it from the release branch whose newest draft should be published. +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish-release: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Publish + shell: bash + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + + series=$(echo "${BRANCH}" | sed -n 's|^release/\([0-9]\{1,\}\.[0-9]\{1,\}\).*|\1|p') + [ -n "${series}" ] || { echo "expected release/.[...], got '${BRANCH}'" >&2 ; exit 1 ; } + + gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate > releases.json + + # A draft has no git tag, so GET /releases/tags/ cannot find it. + # The branch's drafts are the -rc ones on its own major.minor series. + drafts=$( + jq -r --arg p "v${series}." ' + .[] | select(.draft and (.tag_name | startswith($p) and endswith("-rc"))) + | "\(.tag_name)\t\(.id)\t\(.target_commitish)"' releases.json + ) + [ -n "${drafts}" ] || { echo "no -rc draft on ${BRANCH}" >&2 ; exit 1 ; } + IFS=$'\t' read -r draft_tag release_id target \ + <<< "$(printf '%s\n' "${drafts}" | sort -V | tail -n1)" + + version="${draft_tag%-rc}" # v3.1.4.7-rc -> v3.1.4.7 + baseline="${version%.*}" # v3.1.4.7 -> v3.1.4 + echo "${version}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \ + || { echo "unexpected version shape ${version}" >&2 ; exit 1 ; } + + sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${target}" --jq .sha) + + # Guards against tagging a commit the draft was never built from. + status=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BRANCH}...${sha}" --jq .status) + case "${status}" in + identical|behind) ;; + *) echo "${draft_tag} targets ${sha}, which is ${status} ${BRANCH}" >&2 ; exit 1 ;; + esac + + # The baseline tag goes on before publishing: publishing fires the + # release workflows, and they must not see a half-tagged release. + if existing=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${baseline}" --jq .object.sha 2>/dev/null) ; then + [ "${existing}" = "${sha}" ] \ + || { echo "${baseline} already exists on ${existing}, not ${sha}" >&2 ; exit 1 ; } + echo "${baseline} already points at ${sha}" + else + gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f "ref=refs/tags/${baseline}" -f "sha=${sha}" + fi + + # Notes cover this patch only, so diff against the previous release on + # the same line rather than letting GitHub pick one across lines. The + # release being published is still a draft, so it is not a candidate. + previous=$( + jq -r --arg p "v${series}." '.[] | select( ( .draft | not ) and ( .tag_name | startswith($p) ) ) | .tag_name' \ + releases.json | sort -V | tail -n1 + ) + notes=$( + gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f "tag_name=${version}" -f "target_commitish=${sha}" \ + ${previous:+-f "previous_tag_name=${previous}"} --jq .body + ) || notes="" + echo "notes generated against ${previous:-}" + + # Renaming the tag drops the -rc; publishing creates it on ${sha}. + # %details_after_publish% is left for release-body-update.yml. + if [ -n "${notes}" ] ; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + -f "tag_name=${version}" -F draft=false \ + -f "body=${notes}"$'\n\n'"%details_after_publish%" + else + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + -f "tag_name=${version}" -F draft=false + fi + + echo "published ${version} from ${BRANCH}, baseline ${baseline} -> ${sha}" diff --git a/.github/workflows/publish-wasm.yml b/.github/workflows/publish-wasm.yml index 5e2d05a87be0..2dc69b9b16c2 100644 --- a/.github/workflows/publish-wasm.yml +++ b/.github/workflows/publish-wasm.yml @@ -2,7 +2,7 @@ name: Publish Wasm module on: release: - types: [ published ] + types: [ released ] workflow_dispatch: inputs: release_tag: @@ -27,7 +27,31 @@ env: IS_DRAFT: ${{ inputs.draft || false }} jobs: + # Only the highest release may move the unversioned CDN path and the npm + # `latest` tag. Publishing an older line is not supposed to happen, but it + # would silently point both at older code. + newest: + timeout-minutes: 5 + runs-on: ubuntu-latest + outputs: + is_newest: ${{ steps.check.outputs.is_newest }} + steps: + - name: Compare against the highest published release + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + highest=$( + gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \ + | jq -r '.[] | select( ( .draft or .prerelease ) | not ) | .tag_name' \ + | grep -E '^v[0-9]+(\.[0-9]+)+$' | sort -V | tail -n1 + ) + [ "${highest}" = "${TAG}" ] && is_newest=true || is_newest=false + echo "highest published: ${highest}; publishing: ${TAG}; is_newest=${is_newest}" + echo "is_newest=${is_newest}" >> $GITHUB_OUTPUT + cdn: + needs: newest permissions: id-token: write # OIDC -> AWS contents: write # download the draft release asset @@ -49,6 +73,7 @@ jobs: - name: Upload to S3 env: BUCKET: s3://cdn-meshlib-js + IS_NEWEST: ${{ needs.newest.outputs.is_newest }} run: | for TGZ in js-meshlib-*.tgz ; do tar -xzf "${TGZ}" --strip-components=1 ; done upload() { @@ -61,7 +86,9 @@ jobs: upload "${BUCKET}/${f}-draft" --cache-control "public, max-age=300" else upload "${BUCKET}/${f}@${TAG}" --cache-control "public, max-age=31536000, immutable" - upload "${BUCKET}/${f}" --cache-control "public, max-age=300" + if [ "${IS_NEWEST}" = "true" ] ; then + upload "${BUCKET}/${f}" --cache-control "public, max-age=300" + fi fi done @@ -73,13 +100,14 @@ jobs: --paths "/meshlib-draft/*" "/meshlib-mt-draft/*" - name: Invalidate latest - if: ${{ env.IS_DRAFT != 'true' }} + if: ${{ env.IS_DRAFT != 'true' && needs.newest.outputs.is_newest == 'true' }} run: | aws cloudfront create-invalidation \ --distribution-id ${{ secrets.CDN_CLOUDFRONT_DISTRIBUTION_ID }} \ --paths "/meshlib/*" "/meshlib-mt/*" npm: + needs: newest permissions: id-token: write # OIDC -> npm trusted publishing contents: write # download the draft release asset @@ -99,12 +127,23 @@ jobs: gh release download "${TAG}" --pattern "js-meshlib-*.tgz" --repo ${{ github.repository }} --clobber - name: Publish npm packages + env: + IS_NEWEST: ${{ needs.newest.outputs.is_newest }} run: | if [ "${IS_DRAFT}" = "true" ] ; then ARGS="--dry-run" else ARGS="--provenance" fi + # An older line still gets published, just not as what `npm install` + # resolves to. `release-` cannot be read as a semver range, + # which npm rejects as a dist-tag. + if [ "${IS_NEWEST}" = "true" ] ; then + DIST_TAG=latest + else + series="${TAG#v}" + DIST_TAG="release-${series%%.*}.$(echo "${series}" | cut -d. -f2)" + fi for TGZ in js-meshlib-*.tgz ; do - npm stage publish "./${TGZ}" --access public --tag latest ${ARGS} + npm stage publish "./${TGZ}" --access public --tag "${DIST_TAG}" ${ARGS} done diff --git a/.github/workflows/release-body-update.yml b/.github/workflows/release-body-update.yml index ef4bbeb74151..e6138269ea5d 100644 --- a/.github/workflows/release-body-update.yml +++ b/.github/workflows/release-body-update.yml @@ -22,27 +22,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Determine latest release tag - id: latest_release - env: - GH_TOKEN: ${{ github.token }} - run: | - LATEST_RELEASE_TAG=$(gh release list --exclude-drafts --repo ${{ github.repository }} --limit 1 | awk '{print $4}') - echo "::set-output name=tag::${LATEST_RELEASE_TAG}" - - - name: Read release tag - id: read_tag - if: github.event.inputs.tag - run: echo "::set-output name=tag::${{ github.event.inputs.tag }}" - - name: Set release tag id: set_tag - run: | - if [[ -z "${{ steps.read_tag.outputs.tag }}" ]]; then - echo "::set-output name=my_tag::${{ steps.latest_release.outputs.tag }}" - else - echo "::set-output name=my_tag::${{ steps.read_tag.outputs.tag }}" - fi + run: echo "my_tag=${{ inputs.tag || github.event.release.tag_name }}" >> $GITHUB_OUTPUT - name: Setup Release Table run: | diff --git a/.github/workflows/release-tests.yml b/.github/workflows/release-tests.yml index 085fadefecb8..273ed7bc18be 100644 --- a/.github/workflows/release-tests.yml +++ b/.github/workflows/release-tests.yml @@ -17,18 +17,26 @@ on: - prod - test workflow_call: + inputs: + version_tag: + description: "Tag of the release under test. Empty falls back to the newest tag in the repo." + default: "" + required: false + type: string jobs: setup: timeout-minutes: 5 runs-on: ubuntu-latest outputs: - version_tag: ${{ steps.get-latest-tag.outputs.tag }} + version_tag: ${{ inputs.version_tag || steps.get-latest-tag.outputs.tag }} steps: - name: Checkout + if: ${{ !inputs.version_tag }} uses: actions/checkout@v7 - uses: actions-ecosystem/action-get-latest-tag@v1 + if: ${{ !inputs.version_tag }} id: get-latest-tag pip-test-linux: diff --git a/.github/workflows/sign-upload-nuget.yml b/.github/workflows/sign-upload-nuget.yml index abd843118ada..fb690a07a13a 100644 --- a/.github/workflows/sign-upload-nuget.yml +++ b/.github/workflows/sign-upload-nuget.yml @@ -2,13 +2,18 @@ name: Sign and Upload NuGet on: release: - types: [ published ] + types: [ released ] workflow_dispatch: inputs: version_tag: description: Tag of the release to notarize required: true type: string + sign: + description: Sign the package before uploading + required: false + type: boolean + default: true beta: description: If true - loads given nuget as beta version required: false @@ -22,18 +27,10 @@ jobs: outputs: version_tag: ${{ steps.get-tag.outputs.version_tag }} steps: - - name: Checkout - if: ${{ github.event_name != 'workflow_dispatch' }} - uses: actions/checkout@v7 - - - uses: actions-ecosystem/action-get-latest-tag@v1 - if: ${{ github.event_name != 'workflow_dispatch' }} - id: get-latest-tag - - name: Output tag from ENV id: get-tag run: | - version_tag=${{ github.event_name == 'workflow_dispatch' && inputs.version_tag || steps.get-latest-tag.outputs.tag }} + version_tag=${{ inputs.version_tag || github.event.release.tag_name }} echo ${version_tag} version_tag=${version_tag%%-*} echo "version_tag=${version_tag}" >> "$GITHUB_OUTPUT" @@ -66,7 +63,10 @@ jobs: if: ${{ inputs.beta }} run: py -3 scripts\nuget_patch\nuget_make_beta.py "MeshLib_${{needs.setup.outputs.version_tag}}.nupkg" + # A release always signs; only a manual run can opt out, to rehearse a + # publish without reaching for the signing token. - name: Sign NuGet package with certificate + if: ${{ github.event_name != 'workflow_dispatch' || inputs.sign }} run: | jsign --storetype ETOKEN --storepass "${{ secrets.WINDOWS_SIGNTOOL_KC_PARAM_TOKEN_PASSWORD }}" -tsaurl http://timestamp.digicert.com MeshLib_${{needs.setup.outputs.version_tag}}.nupkg nuget verify -All -Verbosity detailed MeshLib_${{needs.setup.outputs.version_tag}}.nupkg diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 0eaa197ab7e3..dbda50aea018 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -7,6 +7,11 @@ on: required: false type: string default: "MeshLib" + run_id: + description: "Run holding the binding artifacts. Empty = this run." + required: false + type: string + default: "" jobs: update-documentation: @@ -44,6 +49,8 @@ jobs: with: name: PythonStubs path: MeshLib/scripts/wheel/meshlib/meshlib + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} - name: Download C bindings headers uses: actions/download-artifact@v8 @@ -51,6 +58,8 @@ jobs: with: name: CBindings path: MeshLib/CbindingsTmp + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} - name: Prepare C bindings folders run: | @@ -63,6 +72,8 @@ jobs: with: name: CsharpBindings path: MeshLib/CsharpBindings + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} - name: Download Wasm definitions uses: actions/download-artifact@v8 @@ -70,6 +81,8 @@ jobs: with: name: WasmBindingsDts path: MeshLib/scripts/npm/meshlib + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} - name: Check for bindings id: check_bindings diff --git a/.github/workflows/versioning-release.yml b/.github/workflows/versioning-release.yml index 71e0923f635a..10d82e4cf758 100644 --- a/.github/workflows/versioning-release.yml +++ b/.github/workflows/versioning-release.yml @@ -48,6 +48,9 @@ jobs: with: name: Release ${{ inputs.release_tag }} tag_name: ${{ inputs.release_tag }} + # Unset, this defaults to the default branch, so publishing would tag + # whatever master's tip is by then instead of the commit built here. + target_commitish: ${{ github.sha }} draft: true prerelease: false body: | diff --git a/docs/release_branches.md b/docs/release_branches.md new file mode 100644 index 000000000000..4910d7774b57 --- /dev/null +++ b/docs/release_branches.md @@ -0,0 +1,74 @@ +## Release branches + +`master` is the development line. A `release/.` branch carries a shipped line, so a fix +can go out without also shipping whatever else landed on `master`. + +Only the newest release branch receives updates. Older ones are frozen — see +[Freezing a line](#freezing-a-line). + +### Versioning + +Both lines use the same scheme, `vA.B.C.N`, computed from the nearest reachable `vA.B.C` tag. A +release branch adds an `-rc` suffix to the tag of its draft releases, and nothing else: the version +stamped into wheels, npm packages, NuGet packages and `.deb`/`.rpm` files is the plain `A.B.C.N`. + +The suffix exists because both lines count from the same tag, so the same `vA.B.C.N` can come up on +each. It keeps their draft releases apart. Publishing drops it. + +### Cutting a branch + +Branch from the commit you want to ship and push it: + +```bash +git switch -c release/3.1 +git push -u origin release/3.1 +``` + +Every push to it then runs the same CI as `master`, plus the wheel build, and attaches the artifacts +to a draft release tagged `vA.B.C.N-rc`. Take fixes onto the branch with `git cherry-pick` — land +them on `master` first. + +Do not merge `master` into a release branch. A merge pulls `master`'s newer tags into the branch's +history and moves its version. + +### Publishing + +Go to Actions, choose **Publish release**, and pick the release branch in the "Run workflow" branch +selector. There are no inputs. + +It takes the newest `-rc` draft on the branch and: + +1. creates the plain `vA.B.C` tag on the draft's commit, +2. republishes the draft under `vA.B.C.N`, without the `-rc`, +3. writes release notes covering only this patch, diffed against the previous release on the same + line. + +Publishing then triggers one workflow per target — **Publish to PyPI**, **Sign and Upload NuGet**, +**Publish Wasm module** and **Publish documentation**. Each ships what is already attached to the +release, so what reaches the registries is what was built and tested on that commit. Nothing is +rebuilt. The C++ distributions need no publish step; the release itself is the channel. + +The workflow refuses to run if the branch is not a `release/` one, if the draft's commit is not on the +branch, or if the `vA.B.C` tag already exists on a different commit. + +### Post-release patches + +A branch is not one-shot. Cherry-pick the next fix, push, and publish again: the `vA.B.C` tag from the +previous publish is what makes the next build come out as `vA.B.(C+1).1-rc`. + +### Freezing a line + +When a newer release branch is cut, stop publishing from the old one. Publishing an older line after a +newer one would point npm's `latest` tag and the unversioned CDN path backwards. + +Publishing checks for this and will still upload the packages, but leaves `latest` and the unversioned +CDN path on the newer release, and puts the npm packages under a `release-.` tag +instead. + +### When a publish half-finishes + +The tags and the release are created before anything is uploaded, so a failure partway leaves the +release published and some registries missing it. Do not publish again — the release already exists. + +Re-run the failed publisher for the tag that was published. Each takes the tag as its only input and +picks the packages up from the release, so re-running is safe and ships the same bytes.