From 522910b50c1d37648de4d80b8950e03d105018e5 Mon Sep 17 00:00:00 2001 From: uartnet <140632163+uartnet@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:31:09 +0200 Subject: [PATCH] ci: promote immutable C++ packages --- .github/scripts/pack-conan-candidate.sh | 48 +++ .github/scripts/pack-release-candidate.sh | 55 +++ .github/scripts/publish-conan-candidate.sh | 26 ++ .github/scripts/verify-release-candidate.sh | 29 ++ .github/workflows/build.yml | 9 + .github/workflows/release-packages.yml | 447 ++++++++++---------- release-please-config.json | 2 + test/test_release_workflow.py | 35 ++ 8 files changed, 424 insertions(+), 227 deletions(-) create mode 100755 .github/scripts/pack-conan-candidate.sh create mode 100755 .github/scripts/pack-release-candidate.sh create mode 100755 .github/scripts/publish-conan-candidate.sh create mode 100755 .github/scripts/verify-release-candidate.sh create mode 100644 test/test_release_workflow.py diff --git a/.github/scripts/pack-conan-candidate.sh b/.github/scripts/pack-conan-candidate.sh new file mode 100755 index 0000000..1a575bd --- /dev/null +++ b/.github/scripts/pack-conan-candidate.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "usage: $0 ..." >&2 + exit 1 +fi + +destination=$1 +source_sha=$2 +shift 2 + +if [[ -e "$destination" ]]; then + echo "candidate destination already exists: ${destination}" >&2 + exit 1 +fi + +temporary=$(mktemp -d) +trap 'rm -rf "$temporary"' EXIT +mkdir -p "$destination" + +lists=() +index=0 +for pattern in "$@"; do + list="$temporary/packages-${index}.json" + conan list "$pattern" --format=json > "$list" + if jq --exit-status '[.[] | keys[]] | length > 0' "$list" >/dev/null; then + lists+=(--list "$list") + fi + index=$((index + 1)) +done + +if [[ ${#lists[@]} -eq 0 ]]; then + echo "no Conan packages matched the candidate patterns" >&2 + exit 1 +fi + +conan pkglist merge "${lists[@]}" --format=json > "$destination/packages.json" +conan cache check-integrity --list "$destination/packages.json" +conan cache save --list "$destination/packages.json" --file "$destination/cache.tgz" +jq -n --arg source "$source_sha" '{schema: 1, source: $source}' > "$destination/manifest.json" + +( + cd "$destination" + shasum -a 256 cache.tgz packages.json manifest.json > SHA256SUMS + shasum -a 256 -c SHA256SUMS +) diff --git a/.github/scripts/pack-release-candidate.sh b/.github/scripts/pack-release-candidate.sh new file mode 100755 index 0000000..0a7456a --- /dev/null +++ b/.github/scripts/pack-release-candidate.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 7 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +destination=$1 +source_sha=$2 +version=$3 +channel=$4 +os=$5 +arch=$6 +package_name=$7 + +if [[ -e "$destination" ]]; then + echo "candidate destination already exists: ${destination}" >&2 + exit 1 +fi + +mkdir -p "$destination/package-api" +cp -R out/release/. "$destination/package-api/" +jq -n \ + --arg source "$source_sha" \ + --arg version "$version" \ + --arg channel "$channel" \ + --arg os "$os" \ + --arg arch "$arch" \ + --arg package_name "$package_name" \ + '{schema: 1, source: $source, version: $version, channel: $channel, os: $os, arch: $arch, packageName: $package_name}' \ + > "$destination/release.json" + +python3 - "$destination" <<'PY' +from hashlib import sha256 +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +with (root / "SHA256SUMS").open("w", encoding="utf-8") as manifest: + files = ( + item + for item in root.rglob("*") + if item.is_file() and item.name != "SHA256SUMS" + ) + for path in sorted(files): + relative = path.relative_to(root).as_posix() + manifest.write(f"{sha256(path.read_bytes()).hexdigest()} {relative}\n") +PY + +( + cd "$destination" + shasum -a 256 -c SHA256SUMS +) diff --git a/.github/scripts/publish-conan-candidate.sh b/.github/scripts/publish-conan-candidate.sh new file mode 100755 index 0000000..e24a46f --- /dev/null +++ b/.github/scripts/publish-conan-candidate.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +candidate=$1 +source_sha=$2 +remote=$3 + +if [[ ! -f "$candidate/manifest.json" || "$(jq -r '.source' "$candidate/manifest.json")" != "$source_sha" ]]; then + echo "Conan candidate does not match the approved commit" >&2 + exit 1 +fi + +( + cd "$candidate" + shasum -a 256 -c SHA256SUMS +) + +conan cache restore "$candidate/cache.tgz" +conan cache check-integrity --list "$candidate/packages.json" +conan upload --list "$candidate/packages.json" -r "$remote" --check --confirm diff --git a/.github/scripts/verify-release-candidate.sh b/.github/scripts/verify-release-candidate.sh new file mode 100755 index 0000000..c479db4 --- /dev/null +++ b/.github/scripts/verify-release-candidate.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 6 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +candidate=$1 +source_sha=$2 +version=$3 +channel=$4 +os=$5 +arch=$6 + +( + cd "$candidate" + shasum -a 256 -c SHA256SUMS +) + +jq --exit-status \ + --arg source "$source_sha" \ + --arg version "$version" \ + --arg channel "$channel" \ + --arg os "$os" \ + --arg arch "$arch" \ + '.source == $source and .version == $version and .channel == $channel and .os == $os and .arch == $arch' \ + "$candidate/release.json" >/dev/null diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e6efdb..3254773 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,15 @@ on: permissions: contents: read jobs: + release-workflow: + name: Release workflow + if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - run: python3 test/test_release_workflow.py build: name: Conan package if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index f1ede92..55609d0 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -1,9 +1,10 @@ name: Release Packages + on: workflow_dispatch: inputs: upload: - description: Upload packages after a successful export. + description: Publish the verified candidates after approval. required: true type: boolean default: false @@ -31,11 +32,14 @@ on: push: tags: - "*" + permissions: contents: read + concurrency: group: release-packages-${{ github.ref }} cancel-in-progress: false + env: CHANNEL: "${{ inputs.channel || (github.ref_type == 'tag' && 'stable' || 'dev') }}" CONAN_VERSION: "2.26.2" @@ -48,13 +52,13 @@ env: PATCHED_CONAN_CHANNEL: conan/stable PATCHED_NCURSES_VERSION: "6.5" RSTREAM_URL: "${{ inputs.rstream_url || (github.ref_type == 'tag' && 'https://rstream.io' || 'https://dev.rstream.io') }}" - UPLOAD_PACKAGES: "${{ github.ref_type == 'tag' || inputs.upload == true }}" USE_PATCHED_CONAN_DEPS: "on" VERSION: "${{ github.ref_name }}" + jobs: package-linux-windows: + name: Build ${{ matrix.target.os }} ${{ matrix.target.arch }} candidate if: ${{ github.actor == vars.CI_ALLOWED_ACTOR && (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') }} - name: Package ${{ matrix.target.os }} ${{ matrix.target.arch }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -133,23 +137,7 @@ jobs: key: conan2-release-${{ env.CONAN_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ matrix.target.os }}-${{ matrix.target.arch }}-${{ env.CHANNEL }}-${{ hashFiles('conanfile.py', 'conan/config/**', 'conan/recipes/**') }} restore-keys: | conan2-release-${{ env.CONAN_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ matrix.target.os }}-${{ matrix.target.arch }}-${{ env.CHANNEL }}- - - name: Validate release tag - if: ${{ github.ref_type == 'tag' }} - shell: bash - run: | - set -euo pipefail - if [[ ! "${VERSION}" =~ ^v?[0-9]+(\.[0-9]+){2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "invalid release tag: ${VERSION}" >&2 - exit 1 - fi - package_version="$(tr -d '[:space:]' < version.txt)" - if [[ "${package_version}" != "${VERSION#v}" ]]; then - echo "release tag ${VERSION#v} does not match version.txt ${package_version}" >&2 - exit 1 - fi - echo "VERSION=${VERSION#v}" >> "${GITHUB_ENV}" - - name: Resolve package version - if: ${{ github.ref_type != 'tag' }} + - name: Resolve and validate package version shell: bash run: | set -euo pipefail @@ -158,6 +146,10 @@ jobs: echo "invalid package version: ${package_version}" >&2 exit 1 fi + if [[ "${{ github.ref_type }}" == "tag" && "${package_version}" != "${VERSION#v}" ]]; then + echo "release tag ${VERSION#v} does not match version.txt ${package_version}" >&2 + exit 1 + fi echo "VERSION=${package_version}" >> "${GITHUB_ENV}" - name: Install dependencies shell: bash @@ -219,23 +211,18 @@ jobs: conan config install conan/config if [[ -n "${CONAN_REMOTE_URL}" ]]; then remote_name="${CONAN_REMOTE_NAME:-rstream}" - if conan remote list | grep -q "^${remote_name}:"; then - conan remote update "${remote_name}" --url "${CONAN_REMOTE_URL}" - else - conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" - fi + conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" --force if [[ -n "${CONAN_REMOTE_USERNAME}" && -n "${CONAN_PASSWORD}" ]]; then conan remote login "${remote_name}" "${CONAN_REMOTE_USERNAME}" -p "${CONAN_PASSWORD}" fi fi - - name: Validate package version - if: ${{ github.ref_type == 'tag' }} + - name: Validate Conan package version shell: bash run: | set -euo pipefail package_version=$(conan inspect . --format=json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') if [[ "${package_version}" != "${VERSION}" ]]; then - echo "release tag ${VERSION} does not match Conan package version ${package_version}" >&2 + echo "version.txt ${VERSION} does not match Conan package version ${package_version}" >&2 exit 1 fi - name: Build and export packages @@ -244,54 +231,18 @@ jobs: set -euo pipefail unset LD_LIBRARY_PATH ./build-conan-cross.sh - - name: Upload Conan binary cache - if: ${{ env.UPLOAD_PACKAGES == 'true' }} + - name: Pack immutable candidate shell: bash - env: - CONAN_PASSWORD: ${{ secrets.CONAN_PASSWORD }} run: | set -euo pipefail - if [[ -z "${CONAN_REMOTE_URL}" || -z "${CONAN_REMOTE_USERNAME}" || -z "${CONAN_PASSWORD}" ]]; then - echo "CONAN_REMOTE_URL, CONAN_REMOTE_USERNAME and CONAN_PASSWORD are required when Conan binary cache upload is enabled." - exit 1 - fi - remote_name="${CONAN_REMOTE_NAME:-rstream}" - conan remote login "${remote_name}" "${CONAN_REMOTE_USERNAME}" -p "${CONAN_PASSWORD}" - refs=("rstream/${VERSION}") - if [[ "${USE_PATCHED_CONAN_DEPS}" == "on" ]]; then - refs+=( - "boost/${PATCHED_BOOST_VERSION}@${PATCHED_CONAN_CHANNEL}" - "ncurses/${PATCHED_NCURSES_VERSION}@${PATCHED_CONAN_CHANNEL}" - ) - fi - has_packages=$(cat <<'PY' - import json - import sys - def has_packages(value): - if isinstance(value, dict): - packages = value.get("packages") - if isinstance(packages, dict) and packages: - return True - return any(has_packages(child) for child in value.values()) - if isinstance(value, list): - return any(has_packages(child) for child in value) - return False - sys.exit(0 if has_packages(json.load(sys.stdin)) else 1) - PY - ) - for ref in "${refs[@]}"; do - if conan list "${ref}:*" --format=json | python3 -c "${has_packages}"; then - conan upload "${ref}:*" -r "${remote_name}" --check --confirm - else - echo "no local Conan packages to upload for ${ref}" - fi - done + ./.github/scripts/pack-release-candidate.sh candidate "${GITHUB_SHA}" "${VERSION}" "${CHANNEL}" "${{ matrix.target.os }}" "${{ matrix.target.arch }}" "${EXPORT_PACKAGE_NAME}" + ./.github/scripts/pack-conan-candidate.sh candidate/conan "${GITHUB_SHA}" \ + "rstream/${VERSION}#latest:*#latest" \ + "boost/${PATCHED_BOOST_VERSION}@${PATCHED_CONAN_CHANNEL}#latest:*#latest" \ + "ncurses/${PATCHED_NCURSES_VERSION}@${PATCHED_CONAN_CHANNEL}#latest:*#latest" - name: Trim Conan cache if: ${{ always() && !cancelled() }} - shell: bash - run: | - set -euo pipefail - conan cache clean "*" --source --build --download --temp + run: conan cache clean "*" --source --build --download --temp - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae if: ${{ always() && !cancelled() && (matrix.target.arch == 'x86_64' || matrix.target.arch == 'arm64') && steps.conan-cache.outputs.cache-hit != 'true' }} with: @@ -299,29 +250,17 @@ jobs: ~/.conan2/p ~/.cache/ccache key: ${{ steps.conan-cache.outputs.cache-primary-key }} - - name: Upload workflow artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: ${{ env.EXPORT_PACKAGE_NAME }}-${{ matrix.target.os }}-${{ matrix.target.arch }}-${{ env.CHANNEL }} - path: out/release/**/${{ matrix.target.os }}/${{ matrix.target.arch }}/**/packages/* + name: cpp-release-candidate-${{ github.sha }}-${{ matrix.target.os }}-${{ matrix.target.arch }} + path: candidate if-no-files-found: error - retention-days: 14 - - name: Upload packages - if: ${{ env.UPLOAD_PACKAGES == 'true' }} - shell: bash - env: - RSTREAM_TOKEN: ${{ secrets.RSTREAM_TOKEN }} - run: | - set -euo pipefail - if [[ -z "${RSTREAM_TOKEN}" ]]; then - echo "RSTREAM_TOKEN is required when package upload is enabled." - exit 1 - fi - unset LD_LIBRARY_PATH - ./build-conan-cross.sh upload + compression-level: 0 + retention-days: 30 + package-macos: + name: Build macos candidate if: ${{ github.actor == vars.CI_ALLOWED_ACTOR && (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') }} - name: Package macos runs-on: macos-latest env: MACOS_APP_STORE_API_KEY: ${{ secrets.MACOS_APP_STORE_API_KEY }} @@ -349,23 +288,7 @@ jobs: restore-keys: | conan2-${{ env.CONAN_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ env.CHANNEL }}- conan2-${{ env.CONAN_VERSION }}-${{ runner.os }}-${{ runner.arch }}- - - name: Validate release tag - if: ${{ github.ref_type == 'tag' }} - shell: bash - run: | - set -euo pipefail - if [[ ! "${VERSION}" =~ ^v?[0-9]+(\.[0-9]+){2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "invalid release tag: ${VERSION}" >&2 - exit 1 - fi - package_version="$(tr -d '[:space:]' < version.txt)" - if [[ "${package_version}" != "${VERSION#v}" ]]; then - echo "release tag ${VERSION#v} does not match version.txt ${package_version}" >&2 - exit 1 - fi - echo "VERSION=${VERSION#v}" >> "${GITHUB_ENV}" - - name: Resolve package version - if: ${{ github.ref_type != 'tag' }} + - name: Resolve and validate package version shell: bash run: | set -euo pipefail @@ -374,6 +297,10 @@ jobs: echo "invalid package version: ${package_version}" >&2 exit 1 fi + if [[ "${{ github.ref_type }}" == "tag" && "${package_version}" != "${VERSION#v}" ]]; then + echo "release tag ${VERSION#v} does not match version.txt ${package_version}" >&2 + exit 1 + fi echo "VERSION=${package_version}" >> "${GITHUB_ENV}" - name: Install dependencies shell: bash @@ -414,78 +341,34 @@ jobs: conan config install conan/config if [[ -n "${CONAN_REMOTE_URL}" ]]; then remote_name="${CONAN_REMOTE_NAME:-rstream}" - if conan remote list | grep -q "^${remote_name}:"; then - conan remote update "${remote_name}" --url "${CONAN_REMOTE_URL}" - else - conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" - fi + conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" --force if [[ -n "${CONAN_REMOTE_USERNAME}" && -n "${CONAN_PASSWORD}" ]]; then conan remote login "${remote_name}" "${CONAN_REMOTE_USERNAME}" -p "${CONAN_PASSWORD}" fi fi - - name: Validate package version - if: ${{ github.ref_type == 'tag' }} + - name: Validate Conan package version shell: bash run: | set -euo pipefail package_version=$(conan inspect . --format=json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') if [[ "${package_version}" != "${VERSION}" ]]; then - echo "release tag ${VERSION} does not match Conan package version ${package_version}" >&2 + echo "version.txt ${VERSION} does not match Conan package version ${package_version}" >&2 exit 1 fi - name: Build and export packages + run: ./build-conan-cross.sh + - name: Pack immutable candidate shell: bash run: | set -euo pipefail - ./build-conan-cross.sh - - name: Upload Conan binary cache - if: ${{ env.UPLOAD_PACKAGES == 'true' }} - shell: bash - env: - CONAN_PASSWORD: ${{ secrets.CONAN_PASSWORD }} - run: | - set -euo pipefail - if [[ -z "${CONAN_REMOTE_URL}" || -z "${CONAN_REMOTE_USERNAME}" || -z "${CONAN_PASSWORD}" ]]; then - echo "CONAN_REMOTE_URL, CONAN_REMOTE_USERNAME and CONAN_PASSWORD are required when Conan binary cache upload is enabled." - exit 1 - fi - remote_name="${CONAN_REMOTE_NAME:-rstream}" - conan remote login "${remote_name}" "${CONAN_REMOTE_USERNAME}" -p "${CONAN_PASSWORD}" - refs=("rstream/${VERSION}") - if [[ "${USE_PATCHED_CONAN_DEPS}" == "on" ]]; then - refs+=( - "boost/${PATCHED_BOOST_VERSION}@${PATCHED_CONAN_CHANNEL}" - "ncurses/${PATCHED_NCURSES_VERSION}@${PATCHED_CONAN_CHANNEL}" - ) - fi - has_packages=$(cat <<'PY' - import json - import sys - def has_packages(value): - if isinstance(value, dict): - packages = value.get("packages") - if isinstance(packages, dict) and packages: - return True - return any(has_packages(child) for child in value.values()) - if isinstance(value, list): - return any(has_packages(child) for child in value) - return False - sys.exit(0 if has_packages(json.load(sys.stdin)) else 1) - PY - ) - for ref in "${refs[@]}"; do - if conan list "${ref}:*" --format=json | python3 -c "${has_packages}"; then - conan upload "${ref}:*" -r "${remote_name}" --check --confirm - else - echo "no local Conan packages to upload for ${ref}" - fi - done + ./.github/scripts/pack-release-candidate.sh candidate "${GITHUB_SHA}" "${VERSION}" "${CHANNEL}" macos all "${EXPORT_PACKAGE_NAME}" + ./.github/scripts/pack-conan-candidate.sh candidate/conan "${GITHUB_SHA}" \ + "rstream/${VERSION}#latest:*#latest" \ + "boost/${PATCHED_BOOST_VERSION}@${PATCHED_CONAN_CHANNEL}#latest:*#latest" \ + "ncurses/${PATCHED_NCURSES_VERSION}@${PATCHED_CONAN_CHANNEL}#latest:*#latest" - name: Trim Conan cache if: ${{ success() }} - shell: bash - run: | - set -euo pipefail - conan cache clean "*" --source --build --download --temp + run: conan cache clean "*" --source --build --download --temp - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae if: ${{ success() && steps.conan-cache.outputs.cache-hit != 'true' }} with: @@ -493,100 +376,210 @@ jobs: ~/.conan2/p ~/.cache/ccache key: ${{ steps.conan-cache.outputs.cache-primary-key }} - - name: Upload workflow artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: ${{ env.EXPORT_PACKAGE_NAME }}-macos-${{ env.CHANNEL }} - path: out/release/**/macos/**/packages/* + name: cpp-release-candidate-${{ github.sha }}-macos + path: candidate if-no-files-found: error - retention-days: 14 - - name: Upload packages - if: ${{ env.UPLOAD_PACKAGES == 'true' }} - shell: bash - env: - RSTREAM_TOKEN: ${{ secrets.RSTREAM_TOKEN }} - run: | - set -euo pipefail - if [[ -z "${RSTREAM_TOKEN}" ]]; then - echo "RSTREAM_TOKEN is required when package upload is enabled." - exit 1 - fi - ./build-conan-cross.sh upload - publish-conan-source: - if: ${{ github.actor == vars.CI_ALLOWED_ACTOR && github.ref_type == 'tag' }} - name: Publish Conan source package + compression-level: 0 + retention-days: 30 + + approve: + name: Approve immutable package candidates needs: - package-linux-windows - package-macos + if: ${{ github.ref_type == 'tag' || inputs.upload == true }} runs-on: ubuntu-latest + environment: stable-release + steps: + - run: printf 'Approved C++ package candidates for %s\n' "${GITHUB_SHA}" + + publish-linux-windows: + name: Publish ${{ matrix.target.os }} ${{ matrix.target.arch }} candidate + needs: approve + permissions: + actions: read + contents: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 6 + matrix: + target: + - os: linux + arch: x86_i686 + - os: linux + arch: x86_core2 + - os: linux + arch: x86_64 + - os: linux + arch: x86_64_v2 + - os: linux + arch: x86_64_v3 + - os: linux + arch: x86_64_v4 + - os: linux + arch: armv6 + - os: linux + arch: armv6hf + - os: linux + arch: armv7 + - os: linux + arch: armv7hf + - os: linux + arch: arm64 + - os: linux + arch: mips + - os: linux + arch: mipsle + - os: linux + arch: mips64 + - os: linux + arch: mips64le + - os: linux + arch: riscv64 + - os: windows + arch: x86_i686 + - os: windows + arch: x86_core2 + - os: windows + arch: x86_64 + - os: windows + arch: x86_64_v2 + - os: windows + arch: x86_64_v3 + - os: windows + arch: x86_64_v4 + - os: windows + arch: arm64 + env: + LINUX_BUILD_SHARED: "off" + LINUX_ARCHS: ${{ matrix.target.os == 'linux' && matrix.target.arch || '' }} + LINUX_TCLIBCS: musl + OSS: ${{ matrix.target.os }} + USE_DOCKER: "off" + WINDOWS_BUILD_SHARED: "off" + WINDOWS_ARCHS: ${{ matrix.target.os == 'windows' && matrix.target.arch || '' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: + ref: ${{ github.sha }} persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: "3.x" - - name: Validate release tag - shell: bash - run: | - set -euo pipefail - if [[ ! "${VERSION}" =~ ^v?[0-9]+(\.[0-9]+){2}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "invalid release tag: ${VERSION}" >&2 - exit 1 - fi - package_version="$(tr -d '[:space:]' < version.txt)" - if [[ "${package_version}" != "${VERSION#v}" ]]; then - echo "release tag ${VERSION#v} does not match version.txt ${package_version}" >&2 - exit 1 - fi - echo "VERSION=${VERSION#v}" >> "${GITHUB_ENV}" - - name: Install dependencies - shell: bash + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cpp-release-candidate-${{ github.sha }}-${{ matrix.target.os }}-${{ matrix.target.arch }} + path: candidate + - name: Install publishing dependencies run: | - set -euo pipefail sudo apt update - sudo apt-get install -y \ - pipx + sudo apt-get install -y jq libdigest-sha-perl pipx pipx install "conan==${CONAN_VERSION}" echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" - - name: Configure Conan - shell: bash + - name: Resolve package version + run: echo "VERSION=$(tr -d '[:space:]' < version.txt)" >> "${GITHUB_ENV}" + - name: Configure publishing remotes + env: + CONAN_PASSWORD: ${{ secrets.CONAN_PASSWORD }} run: | set -euo pipefail - conan config install conan/config - if [[ -n "${CONAN_REMOTE_URL}" ]]; then - remote_name="${CONAN_REMOTE_NAME:-rstream}" - if conan remote list | grep -q "^${remote_name}:"; then - conan remote update "${remote_name}" --url "${CONAN_REMOTE_URL}" - else - conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" - fi + if [[ -z "${CONAN_REMOTE_URL}" || -z "${CONAN_REMOTE_USERNAME}" || -z "${CONAN_PASSWORD}" ]]; then + echo "Conan publishing credentials are required." >&2 + exit 1 fi - - name: Validate package version - shell: bash + remote_name="${CONAN_REMOTE_NAME:-rstream}" + conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" --force + conan remote login "${remote_name}" "${CONAN_REMOTE_USERNAME}" -p "${CONAN_PASSWORD}" + - name: Publish exact candidate + env: + RSTREAM_TOKEN: ${{ secrets.RSTREAM_TOKEN }} run: | set -euo pipefail - package_version=$(conan inspect . --format=json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') - if [[ "${package_version}" != "${VERSION}" ]]; then - echo "release tag ${VERSION} does not match Conan package version ${package_version}" >&2 + if [[ -z "${RSTREAM_TOKEN}" ]]; then + echo "RSTREAM_TOKEN is required." >&2 exit 1 fi - - name: Export Conan recipe - shell: bash + ./.github/scripts/verify-release-candidate.sh candidate "${GITHUB_SHA}" "${VERSION}" "${CHANNEL}" "${{ matrix.target.os }}" "${{ matrix.target.arch }}" + ./.github/scripts/publish-conan-candidate.sh candidate/conan "${GITHUB_SHA}" "${CONAN_REMOTE_NAME:-rstream}" + mkdir -p out/release + cp -R candidate/package-api/. out/release/ + ./build-conan-cross.sh upload + + publish-macos: + name: Publish macos candidate + needs: approve + permissions: + actions: read + contents: read + runs-on: macos-latest + env: + MACOS_ARCHS: "x86_64 x86_64_v2 x86_64_v3 x86_64_v4 arm64 apple-m1 apple-m2" + MACOS_BUILD_SHARED: "on" + OSS: macos + USE_DOCKER: "off" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: "3.x" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cpp-release-candidate-${{ github.sha }}-macos + path: candidate + - name: Install publishing dependencies run: | - set -euo pipefail - conan export . - - name: Upload Conan source package - shell: bash + brew list bash >/dev/null 2>&1 || brew install bash + brew list jq >/dev/null 2>&1 || brew install jq + echo "$(brew --prefix bash)/bin" >> "${GITHUB_PATH}" + python3 -m pip install --user "conan==${CONAN_VERSION}" + echo "$(python3 -m site --user-base)/bin" >> "${GITHUB_PATH}" + - name: Resolve package version + run: echo "VERSION=$(tr -d '[:space:]' < version.txt)" >> "${GITHUB_ENV}" + - name: Configure publishing remotes env: - CONAN_LOGIN_USERNAME: ${{ env.CONAN_REMOTE_USERNAME }} CONAN_PASSWORD: ${{ secrets.CONAN_PASSWORD }} run: | set -euo pipefail - if [[ -z "${CONAN_REMOTE_URL}" || -z "${CONAN_LOGIN_USERNAME}" || -z "${CONAN_PASSWORD}" ]]; then - echo "CONAN_REMOTE_URL, CONAN_REMOTE_USERNAME and CONAN_PASSWORD are required on release tags." + if [[ -z "${CONAN_REMOTE_URL}" || -z "${CONAN_REMOTE_USERNAME}" || -z "${CONAN_PASSWORD}" ]]; then + echo "Conan publishing credentials are required." >&2 exit 1 fi remote_name="${CONAN_REMOTE_NAME:-rstream}" - conan remote login "${remote_name}" "${CONAN_LOGIN_USERNAME}" -p "${CONAN_PASSWORD}" - conan upload "rstream/${VERSION}" --only-recipe -r "${remote_name}" --check --confirm + conan remote add "${remote_name}" "${CONAN_REMOTE_URL}" --force + conan remote login "${remote_name}" "${CONAN_REMOTE_USERNAME}" -p "${CONAN_PASSWORD}" + - name: Publish exact candidate + env: + RSTREAM_TOKEN: ${{ secrets.RSTREAM_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${RSTREAM_TOKEN}" ]]; then + echo "RSTREAM_TOKEN is required." >&2 + exit 1 + fi + ./.github/scripts/verify-release-candidate.sh candidate "${GITHUB_SHA}" "${VERSION}" "${CHANNEL}" macos all + ./.github/scripts/publish-conan-candidate.sh candidate/conan "${GITHUB_SHA}" "${CONAN_REMOTE_NAME:-rstream}" + mkdir -p out/release + cp -R candidate/package-api/. out/release/ + ./build-conan-cross.sh upload + + verify-publication: + name: Verify package publication completed + needs: + - publish-linux-windows + - publish-macos + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - run: printf 'Published verified C++ package candidates for %s\n' "${GITHUB_SHA}" + - name: Publish GitHub release + if: ${{ github.ref_type == 'tag' }} + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${GITHUB_REF_NAME}" --draft=false --repo "${GITHUB_REPOSITORY}" diff --git a/release-please-config.json b/release-please-config.json index 8d724eb..54d1d01 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "draft": true, + "force-tag-creation": true, "pull-request-header": "New release created", "packages": { ".": { diff --git a/test/test_release_workflow.py b/test/test_release_workflow.py new file mode 100644 index 0000000..02b590d --- /dev/null +++ b/test/test_release_workflow.py @@ -0,0 +1,35 @@ +import json +import unittest +from pathlib import Path + + +class ReleaseWorkflowTest(unittest.TestCase): + def setUp(self) -> None: + self.workflow = Path(".github/workflows/release-packages.yml").read_text( + encoding="utf-8" + ) + self.config = json.loads( + Path("release-please-config.json").read_text(encoding="utf-8") + ) + + def test_publication_uses_approved_candidates(self) -> None: + candidate, publication = self.workflow.split(" approve:\n", maxsplit=1) + + self.assertNotIn("conan upload", candidate) + self.assertNotIn("RSTREAM_TOKEN", candidate) + self.assertNotIn("./build-conan-cross.sh upload", candidate) + self.assertIn("environment: stable-release", publication) + self.assertIn("actions/download-artifact@", publication) + self.assertIn("publish-conan-candidate.sh", publication) + self.assertIn("verify-release-candidate.sh", publication) + + def test_github_release_stays_draft_until_publication(self) -> None: + self.assertTrue(self.config["draft"]) + self.assertTrue(self.config["force-tag-creation"]) + self.assertIn( + 'gh release edit "${GITHUB_REF_NAME}" --draft=false', self.workflow + ) + + +if __name__ == "__main__": + unittest.main()