From f3d3e66c2a552d48e885625a57b05166f46d1cdd Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 11 Nov 2026 05:25:41 -0800 Subject: [PATCH 1/4] feat(release): add release actions for program repositories Composite actions, scripts and example workflows for building, packaging and releasing MODFLOW program repositories, generalized from mfusg's workflows. Moved from MODFLOW-ORG/release-tooling, so the actions are versioned with modflow-devtools. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 15 + .github/workflows/actions.yml | 165 +++++ README.md | 1 + actions/changelog/action.yml | 75 +++ actions/check-linkage/action.yml | 62 ++ actions/package/action.yml | 91 +++ actions/release-finish/action.yml | 159 +++++ actions/release-start/action.yml | 118 ++++ actions/setup/action.yml | 76 +++ actions/version/action.yml | 58 ++ autotest/release/meson.build | 40 ++ autotest/release/meson.options | 1 + autotest/release/pixi.lock | 851 ++++++++++++++++++++++++++ autotest/release/pixi.toml | 14 + autotest/release/release.toml | 4 + autotest/release/src/hello.f90 | 17 + autotest/test_release.py | 145 +++++ docs/examples/build.yml | 89 +++ docs/examples/release.yml | 69 +++ docs/index.rst | 1 + docs/md/release.md | 212 +++++++ modflow_devtools/release/__init__.py | 3 + modflow_devtools/release/changelog.py | 90 +++ modflow_devtools/release/cliff.toml | 41 ++ modflow_devtools/release/version.py | 149 +++++ pyproject.toml | 1 + 26 files changed, 2547 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/actions.yml create mode 100644 actions/changelog/action.yml create mode 100644 actions/check-linkage/action.yml create mode 100644 actions/package/action.yml create mode 100644 actions/release-finish/action.yml create mode 100644 actions/release-start/action.yml create mode 100644 actions/setup/action.yml create mode 100644 actions/version/action.yml create mode 100644 autotest/release/meson.build create mode 100644 autotest/release/meson.options create mode 100644 autotest/release/pixi.lock create mode 100644 autotest/release/pixi.toml create mode 100644 autotest/release/release.toml create mode 100644 autotest/release/src/hello.f90 create mode 100644 autotest/test_release.py create mode 100644 docs/examples/build.yml create mode 100644 docs/examples/release.yml create mode 100644 docs/md/release.md create mode 100644 modflow_devtools/release/__init__.py create mode 100644 modflow_devtools/release/changelog.py create mode 100644 modflow_devtools/release/cliff.toml create mode 100644 modflow_devtools/release/version.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..e6a6646a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# Program repos pin the release actions (actions/) at a devtools release, so +# updates to the actions they use land here once instead of in every program +# repo. +version: 2 +updates: + - package-ecosystem: github-actions + directories: + - / + - /actions/* + schedule: + interval: monthly + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml new file mode 100644 index 00000000..729c1377 --- /dev/null +++ b/.github/workflows/actions.yml @@ -0,0 +1,165 @@ +# Tests for the release actions (actions/) and scripts +# (modflow_devtools/release/): the scripts, the workflow syntax, and the +# composite actions against a small Fortran program (autotest/release) on each +# released platform. +name: Release actions +on: + push: + paths: &paths + - 'actions/**' + - 'modflow_devtools/release/**' + - 'autotest/release/**' + - 'autotest/test_release.py' + - 'docs/examples/**' + - '.github/workflows/actions.yml' + pull_request: + branches: + - main + - develop + paths: *paths + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +env: + PIXI_BETA_WARNING_OFF: true +jobs: + scripts: + name: Scripts + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup uv + uses: astral-sh/setup-uv@v7 + + - name: Install + run: | + uv sync --group test + pipx install git-cliff==2.14.2 + + - name: Test + working-directory: autotest + run: uv run pytest -v test_release.py + + lint: + name: Lint workflows + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run actionlint + run: | + bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) 1.7.7 + ./actionlint -color .github/workflows/actions.yml + + # the examples call each other, so lint them as a repo's workflows + - name: Lint examples + run: | + mkdir -p "$RUNNER_TEMP/examples/.github/workflows" + cp docs/examples/*.yml "$RUNNER_TEMP/examples/.github/workflows/" + git init -q "$RUNNER_TEMP/examples" + cd "$RUNNER_TEMP/examples" && "$GITHUB_WORKSPACE/actionlint" -color + + release-start: + name: Release start (no-op) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + # not a release event, so there should be nothing to do + - name: Start release + id: start + uses: ./actions/release-start + with: + path: autotest/release + long-name: Hello + + - name: Check stage + env: + STAGE: ${{ steps.start.outputs.stage }} + run: '[[ -z "$STAGE" ]]' + + actions: + name: Actions ${{ matrix.os }} ${{ matrix.compiler }} ${{ matrix.version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-22.04, compiler: intel, version: "2025.0"} + - {os: macos-15, compiler: gcc, version: "13", xcode-version: "16.4"} + - {os: windows-2022, compiler: intel, version: "2025.0"} + - {os: ubuntu-22.04, compiler: gcc, version: "13"} + - {os: windows-2022, compiler: gcc, version: "13"} + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set version + id: version + uses: ./actions/version + with: + path: autotest/release + set: 1.2.3 + + - name: Check version + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + RELEASE: ${{ steps.version.outputs.release }} + run: | + [[ "$VERSION" == "1.2.3" && "$RELEASE" == "true" ]] + grep -q "VERSION = '1.2.3 " autotest/release/src/hello.f90 + + - name: Setup + uses: ./actions/setup + with: + path: autotest/release + compiler: ${{ matrix.compiler }} + compiler-version: ${{ matrix.version }} + xcode-version: ${{ matrix.xcode-version }} + + - name: Build and test + shell: bash + working-directory: autotest/release + run: | + pixi run setup builddir + pixi run build builddir + pixi run setup builddir-dble -Ddouble=true + pixi run build builddir-dble + pixi run test + pixi run meson test --no-rebuild -C builddir-dble + + - name: Check linkage + uses: ./actions/check-linkage + with: + path: autotest/release/bin + + - name: Package + id: package + uses: ./actions/package + with: + path: autotest/release + name: hello + + - name: Check archives + shell: bash + env: + DIST: ${{ steps.package.outputs.dist }} + OSTAG: ${{ steps.package.outputs.ostag }} + run: | + ls -l "$DIST" + python3 - "$DIST/$OSTAG.zip" <<'EOF' + import sys, zipfile + names = sorted(zipfile.ZipFile(sys.argv[1]).namelist()) + print(names) + stems = sorted(n.removesuffix(".exe") for n in names) + assert stems == ["hello", "hellodbl"], names + EOF + if [[ "$RUNNER_OS" == "Linux" ]]; then + unzip -l "$DIST/hello_source.zip" | grep -q src/hello.f90 + fi diff --git a/README.md b/README.md index 3e0516c3..6fc0bd05 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Python development tools for MODFLOW 6 and related projects. - [`MODFLOW-ORG/modflow6-examples`](https://github.com/MODFLOW-ORG/modflow6-examples) - [`MODFLOW-ORG/modflow6-testmodels`](https://github.com/MODFLOW-ORG/modflow6-testmodels) - [`MODFLOW-ORG/modflow6-largetestmodels`](https://github.com/MODFLOW-ORG/modflow6-largetestmodels) +* GitHub Actions for building, packaging and releasing MODFLOW programs, see [release actions](docs/md/release.md) ## Requirements diff --git a/actions/changelog/action.yml b/actions/changelog/action.yml new file mode 100644 index 00000000..bad61858 --- /dev/null +++ b/actions/changelog/action.yml @@ -0,0 +1,75 @@ +name: Changelog +description: > + Prepend a release's features, fixes and performance changes to + CHANGELOG.md with git-cliff (command `update`; needs full git history), or + write a release's section of CHANGELOG.md to a notes file (command `notes`). +inputs: + path: + description: Directory of the program repo checkout + default: . + command: + description: update or notes + required: true + version: + description: Release version, X.Y.Z + required: true + long-name: + description: Program long name, e.g. MODFLOW-USG; used for the shared template and the notes title + required: true + git-cliff-version: + description: git-cliff version + default: 2.14.2 +outputs: + notes-file: + description: Path of the notes file (command `notes`) + value: ${{ steps.notes.outputs.file }} +runs: + using: composite + steps: + - name: Setup Python + id: python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + update-environment: false + + - name: Install git-cliff + if: inputs.command == 'update' + shell: bash + env: + GIT_CLIFF_VERSION: ${{ inputs.git-cliff-version }} + run: pipx install "git-cliff==$GIT_CLIFF_VERSION" + + - name: Update CHANGELOG.md + if: inputs.command == 'update' + shell: bash + working-directory: ${{ inputs.path }} + env: + SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/changelog.py + PYTHON: ${{ steps.python.outputs.python-path }} + VERSION: ${{ inputs.version }} + LONG_NAME: ${{ inputs.long-name }} + REPOSITORY: ${{ github.repository }} + run: | + "$PYTHON" "$SCRIPT" update --version "$VERSION" \ + --repository "$REPOSITORY" --long-name "$LONG_NAME" + + - name: Write release notes + id: notes + if: inputs.command == 'notes' + shell: bash + working-directory: ${{ inputs.path }} + env: + SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/changelog.py + PYTHON: ${{ steps.python.outputs.python-path }} + VERSION: ${{ inputs.version }} + LONG_NAME: ${{ inputs.long-name }} + run: | + file="$RUNNER_TEMP/notes.md" + { + echo "$LONG_NAME version $VERSION" + echo + "$PYTHON" "$SCRIPT" notes --version "$VERSION" + } > "$file" + cat "$file" + echo "file=$file" >> "$GITHUB_OUTPUT" diff --git a/actions/check-linkage/action.yml b/actions/check-linkage/action.yml new file mode 100644 index 00000000..4b13bb4f --- /dev/null +++ b/actions/check-linkage/action.yml @@ -0,0 +1,62 @@ +name: Check linkage +description: > + List the shared libraries every executable in a directory links, and fail + if any links a compiler runtime that users would not have: the Intel + runtime on Linux, or the gcc/gfortran runtime on macOS. On macOS, also + require the expected architecture. Lists dependencies on Windows when + objdump is available, without failing. +inputs: + path: + description: Directory holding the executables + default: bin + macos-arch: + description: Architecture every macOS executable must contain + default: arm64 +runs: + using: composite + steps: + - name: Check linkage + shell: bash + working-directory: ${{ inputs.path }} + env: + ARCH: ${{ inputs.macos-arch }} + run: | + status=0 + case "$RUNNER_OS" in + Linux) + forbidden='libifcore|libifport|libimf|libsvml|libintlc|libirng' + for exe in $(find . -maxdepth 1 -type f -perm -u+x | sort); do + echo "::group::$exe"; ldd "$exe" || true; echo "::endgroup::" + if ldd "$exe" | grep -E "$forbidden"; then + echo "::error::$exe dynamically links the Intel runtime" + status=1 + fi + done + ;; + macOS) + forbidden='libgfortran|libgcc_s|libquadmath|/libstdc\+\+' + for exe in $(find . -maxdepth 1 -type f -perm -u+x | sort); do + echo "::group::$exe"; otool -L "$exe"; lipo -info "$exe"; echo "::endgroup::" + if otool -L "$exe" | grep -E "$forbidden"; then + echo "::error::$exe dynamically links the gcc runtime" + status=1 + fi + if ! lipo -archs "$exe" | grep -qw "$ARCH"; then + echo "::error::$exe is not built for $ARCH" + status=1 + fi + done + ;; + Windows) + if command -v objdump >/dev/null; then + for exe in *.exe; do + echo "::group::$exe" + objdump -p "$exe" | grep -i 'DLL Name' || true + echo "::endgroup::" + done + else + echo "objdump not found, not listing dependencies" + fi + ;; + esac + exit $status diff --git a/actions/package/action.yml b/actions/package/action.yml new file mode 100644 index 00000000..b02ca62c --- /dev/null +++ b/actions/package/action.yml @@ -0,0 +1,91 @@ +name: Package a MODFLOW program +description: > + Zip the executables in bin/ as .zip (linux, mac, macarm or win64), + with the files at the archive root, and on Linux also zip the source as + _source.zip. Outputs the directory holding the archives, and + optionally uploads them as artifact dist- for the release-finish + action. +inputs: + path: + description: Directory of the program repo checkout + default: . + name: + description: Program name, used for the source archive + required: true + source-paths: + description: Space-separated paths to put in the source archive; empty for none + default: meson.build meson.options src + upload: + description: > + Upload the archives as artifact dist-; do this from one job per + platform in a release build + default: "false" +outputs: + ostag: + description: The platform tag + value: ${{ steps.ostag.outputs.ostag }} + dist: + description: Directory holding the archives + value: ${{ steps.ostag.outputs.dist }} +runs: + using: composite + steps: + - name: Set ostag + id: ostag + shell: bash + working-directory: ${{ inputs.path }} + run: | + case "$RUNNER_OS-$RUNNER_ARCH" in + Linux-X64) ostag=linux ;; + macOS-ARM64) ostag=macarm ;; + macOS-X64) ostag=mac ;; + Windows-X64) ostag=win64 ;; + *) echo "::error::no ostag for $RUNNER_OS $RUNNER_ARCH"; exit 1 ;; + esac + dist="$RUNNER_TEMP/dist" + mkdir -p "$dist" + echo "ostag=$ostag" >> "$GITHUB_OUTPUT" + echo "dist=$dist" >> "$GITHUB_OUTPUT" + + - name: Zip executables + if: runner.os != 'Windows' + shell: bash + working-directory: ${{ inputs.path }} + env: + OSTAG: ${{ steps.ostag.outputs.ostag }} + DIST: ${{ steps.ostag.outputs.dist }} + run: | + zip -r -j "$DIST/$OSTAG.zip" bin/ + unzip -l "$DIST/$OSTAG.zip" + + - name: Zip executables (Windows) + if: runner.os == 'Windows' + shell: pwsh + working-directory: ${{ inputs.path }} + env: + OSTAG: ${{ steps.ostag.outputs.ostag }} + DIST: ${{ steps.ostag.outputs.dist }} + run: | + Compress-Archive -Path "bin\*" -DestinationPath "$env:DIST\$env:OSTAG.zip" + Get-ChildItem $env:DIST + + - name: Zip source + if: runner.os == 'Linux' && inputs.source-paths != '' + shell: bash + working-directory: ${{ inputs.path }} + env: + NAME: ${{ inputs.name }} + SOURCE_PATHS: ${{ inputs.source-paths }} + DIST: ${{ steps.ostag.outputs.dist }} + run: | + # shellcheck disable=SC2086 + zip -r "$DIST/${NAME}_source.zip" $SOURCE_PATHS + ls -l "$DIST" + + - name: Upload archives + if: inputs.upload == 'true' + uses: actions/upload-artifact@v7 + with: + name: dist-${{ steps.ostag.outputs.ostag }} + path: ${{ steps.ostag.outputs.dist }}/*.zip + if-no-files-found: error diff --git a/actions/release-finish/action.yml b/actions/release-finish/action.yml new file mode 100644 index 00000000..08201d36 --- /dev/null +++ b/actions/release-finish/action.yml @@ -0,0 +1,159 @@ +name: Finish a release +description: > + Last step of a release workflow, after the release-start action and the + build. For stage `prepare`, open the release pull request into the main + branch. For stage `publish`, download the dist-* artifacts uploaded by the + package action, check them, publish the GitHub release, and open a pull + request setting the next development version on the development branch. + Expects a checkout of the commit being released. +inputs: + path: + description: Directory of the program repo checkout + default: . + stage: + description: The release-start action's stage output + required: true + ref: + description: The release-start action's ref output + required: true + version: + description: The release-start action's version output + required: true + name: + description: Program name, e.g. mfusg; used for the source archive and release title + required: true + long-name: + description: Program long name, e.g. MODFLOW-USG; used in the release notes + required: true + main-branch: + description: Branch releases are merged into and tagged on + default: main + develop-branch: + description: Branch development happens on, if not the main branch + default: "" + platforms: + description: Space-separated OS tags that must each have an archive + default: linux macarm win64 + github-token: + description: Token for creating the release and pull requests + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Check stage + shell: bash + env: + STAGE: ${{ inputs.stage }} + run: | + if [[ "$STAGE" != "prepare" && "$STAGE" != "publish" ]]; then + echo "::error::Unexpected stage '$STAGE', expected prepare or publish." + exit 1 + fi + + - name: Open release pull request + if: inputs.stage == 'prepare' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + BRANCH: ${{ inputs.ref }} + VERSION: ${{ inputs.version }} + MAIN_BRANCH: ${{ inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch || inputs.main-branch }} + run: | + body="Review the $VERSION section of CHANGELOG.md, it becomes the release notes. Merging this publishes release v$VERSION and opens a pull request setting the next development version on $DEVELOP_BRANCH." + gh pr create -R "$GITHUB_REPOSITORY" -B "$MAIN_BRANCH" -H "$BRANCH" \ + --title "chore(release): $VERSION" --body "$body" + + - name: Setup Python + if: inputs.stage == 'publish' + id: python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + update-environment: false + + - name: Download archives + if: inputs.stage == 'publish' + uses: actions/download-artifact@v8 + with: + pattern: dist-* + merge-multiple: true + path: ${{ runner.temp }}/dist + + - name: Check archives + if: inputs.stage == 'publish' + shell: bash + env: + DIST: ${{ runner.temp }}/dist + NAME: ${{ inputs.name }} + PLATFORMS: ${{ inputs.platforms }} + run: | + ls -l "$DIST" + status=0 + for ostag in $PLATFORMS; do + if [[ ! -f "$DIST/$ostag.zip" ]]; then + echo "::error::Missing $ostag.zip." + status=1 + fi + done + for file in "$DIST"/*; do + base=$(basename "$file") + case "$base" in + linux.zip|mac.zip|macarm.zip|win64.zip|"${NAME}_source.zip") ;; + *) echo "::error::Unexpected file $base."; status=1 ;; + esac + done + exit $status + + - name: Publish release + if: inputs.stage == 'publish' + shell: bash + working-directory: ${{ inputs.path }} + env: + GH_TOKEN: ${{ inputs.github-token }} + SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/changelog.py + PYTHON: ${{ steps.python.outputs.python-path }} + DIST: ${{ runner.temp }}/dist + NOTES: ${{ runner.temp }}/notes.md + VERSION: ${{ inputs.version }} + REF: ${{ inputs.ref }} + NAME: ${{ inputs.name }} + LONG_NAME: ${{ inputs.long-name }} + run: | + { + echo "$LONG_NAME version $VERSION" + echo + "$PYTHON" "$SCRIPT" notes --version "$VERSION" + } > "$NOTES" + cat "$NOTES" + gh release create "v$VERSION" "$DIST"/* -R "$GITHUB_REPOSITORY" \ + --target "$REF" --title "$NAME $VERSION" --notes-file "$NOTES" --latest + + # in the dual-branch model this also merges the release back into the + # development branch, so it must be merged with a merge commit + - name: Open next version pull request + if: inputs.stage == 'publish' + shell: bash + working-directory: ${{ inputs.path }} + env: + GH_TOKEN: ${{ inputs.github-token }} + SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/version.py + PYTHON: ${{ steps.python.outputs.python-path }} + VERSION: ${{ inputs.version }} + MAIN_BRANCH: ${{ inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch || inputs.main-branch }} + run: | + next=$("$PYTHON" "$SCRIPT" --next) + branch="release/v$next" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + "$PYTHON" "$SCRIPT" --set "$next" + git commit -am "chore(release): set version to $next" + git push origin "$branch" + body="Set the version on $DEVELOP_BRANCH to $next after the $VERSION release." + if [[ "$DEVELOP_BRANCH" != "$MAIN_BRANCH" ]]; then + body="$body This also merges $MAIN_BRANCH into $DEVELOP_BRANCH. Merge it with a merge commit, not a squash, so the v$VERSION tag is reachable from $DEVELOP_BRANCH." + fi + gh pr create -R "$GITHUB_REPOSITORY" -B "$DEVELOP_BRANCH" -H "$branch" \ + --title "chore(release): set version to $next" --body "$body" diff --git a/actions/release-start/action.yml b/actions/release-start/action.yml new file mode 100644 index 00000000..ad3d3a73 --- /dev/null +++ b/actions/release-start/action.yml @@ -0,0 +1,118 @@ +name: Start a release +description: > + First step of a release workflow. On workflow_dispatch from the development + branch, create release/vX.Y.Z, set the version, update CHANGELOG.md, and + push the branch (stage `prepare`). On push to the main branch, if the + version there is X.Y.Z and untagged, select the commit for release (stage + `publish`). Otherwise do nothing (empty stage). Build the `ref` output + when the stage is set, then run release-finish. +inputs: + path: + description: Directory of the program repo checkout + default: . + version: + description: Version to release, X.Y.Z (workflow_dispatch only) + default: "" + long-name: + description: Program long name, e.g. MODFLOW-USG; used in the shared changelog template + required: true + main-branch: + description: Branch releases are merged into and tagged on + default: main + develop-branch: + description: Branch development happens on, if not the main branch + default: "" +outputs: + stage: + description: prepare, publish, or empty if there is nothing to do + value: ${{ steps.stage.outputs.stage }} + ref: + description: Branch or commit to build and release + value: ${{ steps.stage.outputs.ref }} + version: + description: Version being released + value: ${{ steps.stage.outputs.version }} +runs: + using: composite + steps: + - name: Setup Python + id: python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + update-environment: false + + - name: Select stage + id: stage + shell: bash + working-directory: ${{ inputs.path }} + env: + SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/version.py + PYTHON: ${{ steps.python.outputs.python-path }} + VERSION: ${{ inputs.version }} + MAIN_BRANCH: ${{ inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch || inputs.main-branch }} + run: | + tagged() { git ls-remote --exit-code --tags origin "refs/tags/v$1" >/dev/null; } + stage="" + ref="" + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$GITHUB_REF_NAME" != "$DEVELOP_BRANCH" ]]; then + echo "::error::Start a release from $DEVELOP_BRANCH, not $GITHUB_REF_NAME." + exit 1 + fi + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version '$VERSION' is not X.Y.Z." + exit 1 + fi + if tagged "$VERSION"; then + echo "::error::Tag v$VERSION already exists." + exit 1 + fi + stage=prepare + ref="release/v$VERSION" + elif [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF_NAME" == "$MAIN_BRANCH" ]]; then + VERSION=$("$PYTHON" "$SCRIPT" --get) + if "$PYTHON" "$SCRIPT" --is-release && ! tagged "$VERSION"; then + stage=publish + ref="$GITHUB_SHA" + fi + fi + echo "stage '$stage', ref '$ref', version '$VERSION'" + { + echo "stage=$stage" + echo "ref=$ref" + echo "version=$VERSION" + } >> "$GITHUB_OUTPUT" + + - name: Install git-cliff + if: steps.stage.outputs.stage == 'prepare' + shell: bash + run: pipx install git-cliff==2.14.2 + + - name: Prepare release branch + if: steps.stage.outputs.stage == 'prepare' + shell: bash + working-directory: ${{ inputs.path }} + env: + SCRIPTS: ${{ github.action_path }}/../../modflow_devtools/release + PYTHON: ${{ steps.python.outputs.python-path }} + VERSION: ${{ steps.stage.outputs.version }} + BRANCH: ${{ steps.stage.outputs.ref }} + LONG_NAME: ${{ inputs.long-name }} + run: | + # git-cliff needs the history and tags since the last release + if [[ "$(git rev-parse --is-shallow-repository)" == "true" ]]; then + git fetch --quiet --unshallow --tags origin + else + git fetch --quiet --tags origin + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + "$PYTHON" "$SCRIPTS/version.py" --set "$VERSION" + "$PYTHON" "$SCRIPTS/changelog.py" update --version "$VERSION" \ + --repository "$GITHUB_REPOSITORY" --long-name "$LONG_NAME" + git add -A + git commit -m "chore(release): set version to $VERSION and update changelog" + git push origin "$BRANCH" diff --git a/actions/setup/action.yml b/actions/setup/action.yml new file mode 100644 index 00000000..50819afc --- /dev/null +++ b/actions/setup/action.yml @@ -0,0 +1,76 @@ +name: Set up a MODFLOW program build +description: > + Set up the compiler and pixi, and on macOS, Xcode and static linking of the + gcc runtime. Building and testing is left to the caller. +inputs: + path: + description: Directory of the program repo checkout, holding pixi.toml + default: . + compiler: + description: Compiler for fortran-lang/setup-fortran (gcc, intel, intel-classic), or none + default: gcc + compiler-version: + description: Compiler version + default: "13" + xcode-version: + description: Xcode version to select on macOS, if any + default: "" + pixi-version: + description: pixi version + default: v0.82.1 +runs: + using: composite + steps: + - name: Setup ${{ inputs.compiler }} ${{ inputs.compiler-version }} + if: inputs.compiler != 'none' + uses: fortran-lang/setup-fortran@v1 + with: + compiler: ${{ inputs.compiler }} + version: ${{ inputs.compiler-version }} + + # setup-fortran leaves CXX unset for intel on Windows + - name: Set CXX (Windows intel) + if: runner.os == 'Windows' && inputs.compiler == 'intel' + shell: pwsh + run: echo "CXX=icx" >> $env:GITHUB_ENV + + - name: Setup pixi + uses: prefix-dev/setup-pixi@v0.11.3 + with: + pixi-version: ${{ inputs.pixi-version }} + manifest-path: ${{ inputs.path }}/pixi.toml + + - name: Set Xcode version (macOS) + if: runner.os == 'macOS' && inputs.xcode-version != '' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ inputs.xcode-version }} + + - name: Show toolchain (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + xcode-select -p + xcrun --show-sdk-version + ld -v 2>&1 | head -2 + if command -v gfortran >/dev/null; then gfortran --version | head -1; fi + + # hide the gcc runtime dylibs so the linker uses the static libraries + - name: Hide gcc dylibs (macOS gcc) + if: runner.os == 'macOS' && inputs.compiler == 'gcc' + shell: bash + env: + GCC_VERSION: ${{ inputs.compiler-version }} + run: | + libpath="$(brew --prefix)/opt/gcc@$GCC_VERSION/lib/gcc/$GCC_VERSION" + for lib in libgcc_s.1.1 libgfortran.5 libquadmath.0 libstdc++.6; do + if [[ -f "$libpath/$lib.dylib" ]]; then + mv "$libpath/$lib.dylib" "$libpath/$lib.dylib.bak" + fi + done + + # classic linker, static libgcc + - name: Set LDFLAGS (macOS gcc) + if: runner.os == 'macOS' && inputs.compiler == 'gcc' + shell: bash + run: echo "LDFLAGS=$LDFLAGS -Wl,-ld_classic -static-libgcc" >> "$GITHUB_ENV" diff --git a/actions/version/action.yml b/actions/version/action.yml new file mode 100644 index 00000000..17407a5d --- /dev/null +++ b/actions/version/action.yml @@ -0,0 +1,58 @@ +name: Program version +description: > + Get, check or set a program's version with modflow_devtools/release/version.py: meson.build + (or the version.txt it reads), pixi.toml, and any files in release.toml. +inputs: + path: + description: Directory of the program repo checkout + default: . + get: + description: Output the current version + default: "false" + next: + description: Output the next minor development version + default: "false" + set: + description: Version to set (X.Y.Z or X.Y.Z.devN) + default: "" +outputs: + version: + description: The current version, or the version set + value: ${{ steps.run.outputs.version }} + next: + description: The next minor development version, when requested + value: ${{ steps.run.outputs.next }} + release: + description: Whether the current version is a release version (X.Y.Z) + value: ${{ steps.run.outputs.release }} +runs: + using: composite + steps: + - name: Setup Python + id: python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + update-environment: false + + - name: Run version script + id: run + shell: bash + working-directory: ${{ inputs.path }} + env: + SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/version.py + PYTHON: ${{ steps.python.outputs.python-path }} + SET: ${{ inputs.set }} + NEXT: ${{ inputs.next }} + run: | + if [[ -n "$SET" ]]; then + "$PYTHON" "$SCRIPT" --set "$SET" + fi + version=$("$PYTHON" "$SCRIPT" --get) + echo "version=$version" >> "$GITHUB_OUTPUT" + if "$PYTHON" "$SCRIPT" --is-release; then release=true; else release=false; fi + echo "release=$release" >> "$GITHUB_OUTPUT" + if [[ "$NEXT" == "true" ]]; then + echo "next=$("$PYTHON" "$SCRIPT" --next)" >> "$GITHUB_OUTPUT" + fi + echo "version $version, release $release" diff --git a/autotest/release/meson.build b/autotest/release/meson.build new file mode 100644 index 00000000..cd2719f7 --- /dev/null +++ b/autotest/release/meson.build @@ -0,0 +1,40 @@ +project( + 'hello', + 'fortran', + version: '0.1.0.dev0', + license: 'CC0', + meson_version: '>= 1.7.0', + default_options: [ + 'b_vscrt=static_from_buildtype', + 'buildtype=release', + ], +) + +is_double = get_option('double') +fc = meson.get_compiler('fortran') +args = [] +if is_double + if fc.get_id() == 'gcc' + args += ['-fdefault-real-8', '-fdefault-double-8'] + elif fc.get_id() == 'intel-llvm-cl' or fc.get_id() == 'intel-cl' + args += ['/real-size:64', '/double-size:64'] + else + args += ['-real-size', '64', '-double-size', '64'] + endif +endif +link_args = [] +if fc.get_id() == 'intel-llvm' or fc.get_id() == 'intel' + link_args += ['-static-intel'] +elif fc.get_id() == 'gcc' and host_machine.system() == 'linux' + link_args += ['-static-libgfortran', '-static-libgcc'] +endif + +name = is_double ? 'hellodbl' : 'hello' +exe = executable( + name, + 'src/hello.f90', + fortran_args: args, + link_args: link_args, + install: true, +) +test('kind', exe, args: [is_double ? '8' : '4']) diff --git a/autotest/release/meson.options b/autotest/release/meson.options new file mode 100644 index 00000000..d715821b --- /dev/null +++ b/autotest/release/meson.options @@ -0,0 +1 @@ +option('double', type: 'boolean', value: false, description: 'Build double precision') diff --git a/autotest/release/pixi.lock b/autotest/release/pixi.lock new file mode 100644 index 00000000..ac69244c --- /dev/null +++ b/autotest/release/pixi.lock @@ -0,0 +1,851 @@ +version: 7 +platforms: +- name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 +- name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 +- name: win-64 + virtual-packages: + - __win=10.0 + - __archspec=0=x86_64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.9.0-hd2095e1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h0737f62_101.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.15.2-h6e52b48_100_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.5-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.9.0-h6111c0a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-h34a968b_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ninja-1.13.2-h3feff0a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.2-h2e4dbab_100_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda + win-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.9.0-hac47afa_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.54.1-hf5d6505_101.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ninja-1.13.2-h477610d_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.15.2-h53f6dd8_100_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-hda273ae_43.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-h072b54c_43.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-h1c3f1cb_43.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 + md5: e675fabcf81499adc7edf58124fb1e01 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 257808 + timestamp: 1785906269155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda + sha256: 901649b1edbe841fd4ee7e2d491a05184ed540eb01cdae506872f344babc1d28 + md5: 2362c70e7e79827c32db35547d9c179b + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.46.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 742109 + timestamp: 1791565468810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda + sha256: 45301f14f4eb66da57cfaf2d265f3eeee73f8b07e99614ee3afc5b26eea91126 + md5: 27dbd7e2984b8fe48e069379636ccfdd + depends: + - ncurses + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.6,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20260512,<3.2.0a0 + size: 135877 + timestamp: 1791327370514 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.9.0-hd2095e1_0.conda + sha256: 7b930a8b6bc6553cb754b4977a11f9c862c719a652fc2d128d89270992ce8beb + md5: 317ecabe4a58b3068c7958dc5eb89e43 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + constrains: + - expat 2.9.0.* + license: MIT + license_family: MIT + run_exports: {} + size: 78894 + timestamp: 1793054221966 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda + sha256: c8c7583ef063bc3c430f1d48298e5ad24f796a35c22ed5b14183325825a61106 + md5: 6525a0b06aa4fd390795f0740636a9dd + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.7.0,<3.8.0a0 + size: 68004 + timestamp: 1787753412298 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda + sha256: e031634c3a928f9594eba868bc46ef5230d6571a364a0c63215b06c4563bfcea + md5: 735629b400b70eeab21a2ba8f5b1c2ce + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==16.2.0=*_7 + - libgomp 16.2.0 he0feb66_7 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1058444 + timestamp: 1790303109576 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda + sha256: 92a0bb350c7be6a4eedcb4e111c60aefd176018e6b1c009a6e025dbe9bbcd758 + md5: d904c7ddd95e026254b4b80808ee9e12 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 640566 + timestamp: 1790303046034 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda + sha256: 9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab + md5: 1390b7c5ac0b1d8e447bc5efa6d3c8c2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 112995 + timestamp: 1786348617826 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda + sha256: 46820b4a835e175940ae20bec00fdddaff804cda27a1408ab1b95e77a2196437 + md5: fcfed1dc5053eb1901b66e7b1fc32588 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 92759 + timestamp: 1786650399772 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h0737f62_101.conda + sha256: c294029f893d05286f1061bbab85b820f1eea73dd859af4bcc619197bcc34810 + md5: 36e097bf5f1e0699e88c8ac801f2dc65 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.54.1,<4.0a0 + size: 980491 + timestamp: 1793051548007 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda + sha256: fa7018298629fc429971fffd6ecba3d27a7b02e4e890d6a93ea01ffebe8cb745 + md5: 9b6707e0caca917fa6ea9ee473f37a5d + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 16.2.0 ha9f2e26_7 + constrains: + - libstdcxx-ng ==16.2.0=*_7 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 6615436 + timestamp: 1790303132935 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_1.conda + sha256: 0e2e8f2daa9306e67f046e31092d637431d8cf0a26c407ab22401d108c2d4631 + md5: d852293faea3c04a6e896b908b43aded + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.4,<3.0a0 + size: 36134 + timestamp: 1793891599274 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 + md5: 0de0122d9570a8ab637c6b73db268389 + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63713 + timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 + md5: ee6c0cd80a60961a1f48aa3e0b91f986 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 911196 + timestamp: 1786355078102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ninja-1.13.2-h171cf75_1.conda + sha256: 2b5f2e865122f9fc0637f520a7cb22e16b9727b2db6761ee16cb5e6404412c81 + md5: 2db7a395d7dc2b6ed5effaf68fb99443 + depends: + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 186767 + timestamp: 1786713048064 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda + sha256: 9c1533f1cf784f0e250919c0c9762d0b0e6bc727a79290ffb7cea1ceca1d038c + md5: 5a51a2ea653f120240b1a9bf76e0aae9 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=15 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.5,<4.0a0 + size: 3195299 + timestamp: 1791098052301 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.15.2-h6e52b48_100_cp315.conda + build_number: 100 + sha256: dd6fdef748df5636029673a834ca3632ce44c86d71e4986d70bcdb2ae5607a25 + md5: 38c3edc23439db3ca383be02ae2b702e + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libedit >=3.1.20260512,<3.2.0a0 + - libexpat >=2.8.5,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - libgcc >=15 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.54.1,<4.0a0 + - libuuid >=2.42.4,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.9,<4.0a0 + - python_abi 3.15.* *_cp315 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.15.* *_cp315 + noarch: + - python + size: 29327137 + timestamp: 1793015518763 + python_site_packages_path: lib/python3.15/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda + build_number: 104 + sha256: a1a241d172c1ccab067ba245206dd048bc3c2d1b84504b53c9468e99adfc16a1 + md5: 676a2f9b1c4fcf57b70aa5c65f6c60d0 + depends: + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - xorg-libx11 >=1.8.13,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3566806 + timestamp: 1787272857910 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda + sha256: 47d682b9f6d6ec9eb1a6e6c3e75ea6273e899e78fb7fc59f81d39745009fbc60 + md5: aa459086047c0e5e27023ab19f8cb86a + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601301 + timestamp: 1786599621503 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-h4c7d964_0.conda + sha256: f1d280d2618de987204102090e83733089c092dc2dd45686fd3dcbe809034f6a + md5: ea9dc39dcd2815202749c1e5bc2a8973 + depends: + - __win + license: ISC + run_exports: {} + size: 130423 + timestamp: 1792772460189 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda + sha256: 95619b9c1184be7175392eeb0ee7eeb5ccf25d280951f4225f2d62e725790be3 + md5: 3fd02989038a1e1cee627c14c2d73f29 + depends: + - __unix + license: ISC + run_exports: {} + size: 130057 + timestamp: 1792772446739 +- conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda + sha256: b8f20bb684aa5b4e7e277eb2ca82d2b1bc109a655e10c651008ef948d90c8884 + md5: 669b846a7346f2f364947410a39c558c + depends: + - python >=3.11 + - ninja >=1.8.2 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 798852 + timestamp: 1790167557353 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + build_number: 9 + sha256: 976c4a6db967a62385aa633daaab19a929f986b979a7ad836b8b70e15ca7aefd + md5: b23461631fe2abdbade0cb64babe7344 + constrains: + - python 3.15.* *_cp315 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6800 + timestamp: 1788302827589 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + sha256: 2b7ca858879f183ce2dcc65b300f48a0133d5430550f35b23d80d405a0788dbf + md5: abccc3810d2b97e570dee487f2b46e38 + license: LicenseRef-Public-Domain + run_exports: {} + size: 119208 + timestamp: 1792758382821 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 + md5: b50612e7d190b8061ab4e7dc119cf4d5 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 124965 + timestamp: 1785906749812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.5-h55c6f16_0.conda + sha256: b4b1fd6107980fed8b4e7ae8b08257b50c0f88b8771a530dc4a561f5d796457b + md5: df8bd5c8eaa14aaa86806be695bda0b1 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 577751 + timestamp: 1793848731635 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda + sha256: 5692a969e49b200c7874d1e869d281753361980b2ecfe9191b571d7e550f8b92 + md5: ef2ab2462744ec6ba3a54c34dcc6a06e + depends: + - ncurses + - __osx >=11.0 + - ncurses >=6.6,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20260512,<3.2.0a0 + size: 108512 + timestamp: 1791327440683 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.9.0-h6111c0a_0.conda + sha256: b155c524118f751ad8b1c823397bab8b7f715f7e3ca10479925867a7e23e7e9d + md5: f85a1fda9a2758a7aef2a7787d9c0203 + depends: + - __osx >=11.0 + constrains: + - expat 2.9.0.* + license: MIT + license_family: MIT + run_exports: {} + size: 69681 + timestamp: 1793054210049 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda + sha256: 2783389a7b9dda04c62cc54c6bbeb03dd3cef24b3fc642715d02a3fa19464a9a + md5: 216bbcc23c11e9695b3db4a8771d76eb + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.7.0,<3.8.0a0 + size: 43637 + timestamp: 1787753403688 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311 + md5: 8ab10323068b107661a4b9a4af84f3b5 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 91720 + timestamp: 1786348695846 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda + sha256: 04cc136c5a956a73aa14e0a160b5822b0b29714e67b299fa1b1fd16dbcba5366 + md5: ff33a4dbd93abc8a798cc4e0e7c8136d + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 73289 + timestamp: 1786651074391 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-h34a968b_101.conda + sha256: c49adf700ad17332b938ac627845a3fdc70a0810660d3ec3814de25b156fbf60 + md5: 6b3b2ea51e2685192a790730c59dbb01 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.54.1,<4.0a0 + size: 949450 + timestamp: 1793051666641 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 + md5: f39288f0ea63ae962e1a2e4f355a0d75 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47822 + timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + sha256: 7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68 + md5: 3dfa0d0316dc246cd44937a557de4501 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 804298 + timestamp: 1786355189145 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ninja-1.13.2-h3feff0a_1.conda + sha256: 3309eaa32a97afb323edb380563206c660637046eed93886d4b6b1b0ca270076 + md5: db95a98a49313f75abcad60aefa720ed + depends: + - libcxx >=19 + - __osx >=11.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 164371 + timestamp: 1786713083876 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda + sha256: 44fbc526456571a9fb8fa18157f6f269d4d4bd40eef20183a93b9c9296f94497 + md5: 45d9179cdf6e53d88aec034246f0dad6 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.5,<4.0a0 + size: 3111573 + timestamp: 1791097959641 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.2-h2e4dbab_100_cp315.conda + build_number: 100 + sha256: 78acd0410df52318db76856658143c687fd91398ec1850a027cf9f143498db13 + md5: b4665b09f097706f547ef7048afee0be + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libedit >=3.1.20260512,<3.2.0a0 + - libexpat >=2.8.5,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.54.1,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.9,<4.0a0 + - python_abi 3.15.* *_cp315 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.15.* *_cp315 + noarch: + - python + size: 13419531 + timestamp: 1793014795433 + python_site_packages_path: lib/python3.15/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda + sha256: 857d89087ae7f2c328cf256728affcb7343031a148b4af847334d248a9cf564c + md5: 90a01bf394d1c594e0eb9258f967d828 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3342183 + timestamp: 1787272852357 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda + sha256: da867f5092eb0cb746d353694f0098031fd9817a4ce7d5743121209ae0f406ca + md5: 4ec2684c73812cc2c3d78379384a39cc + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 433687 + timestamp: 1786599629846 +- conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda + sha256: 04767466ee9227c9c57ab2c6503e0149177d34111c7418d2f420297acb1eb229 + md5: c3301c058362f340100d91cd8be0393f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 55919 + timestamp: 1785906343696 +- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.9.0-hac47afa_0.conda + sha256: d4a8e5aac8999a90cd71aa6f9f5c40d8594ed2b89cd7f898f997767ecc6cd52d + md5: 74ac404611024dc488ae71cfb7a5bf8f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - expat 2.9.0.* + license: MIT + license_family: MIT + run_exports: {} + size: 72983 + timestamp: 1793054271728 +- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda + sha256: 613e8077c5cca5df7fe84ade7d5050301bee3c6c4c450ffe61d34a349ab4f11c + md5: ceb38b0ba900847d8da4289c3c8e5708 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.7.0,<3.8.0a0 + size: 49992 + timestamp: 1787753517346 +- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda + sha256: d36c4a1e1f80fd08e18a407e03622ff2f34dfdd022da6488ad19603dea19e6d5 + md5: 880a0c8549479b198af21ba5dc49b109 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 105809 + timestamp: 1786348717883 +- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda + sha256: f07e451de3db1836b87f7aedf95c8e65cdb06c0e6105329ba24bb5f7b5c75e2a + md5: 5ae92fd6614edd024576e14069d7ad4c + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 89109 + timestamp: 1786650384519 +- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.54.1-hf5d6505_101.conda + sha256: 1f7ddd7d9383cf6ab6ee5a3a84e07a679a8539edc0ddd05fe476ff08142bf742 + md5: 3fd1cac4336c12f2fb5545a999d7e9b6 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: blessing + run_exports: + weak: + - libsqlite >=3.54.1,<4.0a0 + size: 1333137 + timestamp: 1793051563198 +- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + sha256: 0629c2cc0404d3bb29d6baa7b4ba62da80797015e86de050db81ea5a07050527 + md5: 5d2ff29d465097458cc3ff6569151991 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 58529 + timestamp: 1785276664143 +- conda: https://conda.anaconda.org/conda-forge/win-64/ninja-1.13.2-h477610d_1.conda + sha256: 002bce37e87e39c6a1e5577b1018d294cd4517ba9c4be2ece92e5706acac41f5 + md5: 3a6804d04ecaf02987a075d5faf34877 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 309772 + timestamp: 1786713090968 +- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda + sha256: bc44625e0c6d83f3e02b5f6adc01e0e3ca7001d3d94e24959591a57f5a011b9a + md5: f002ec2237836867bfd3d1299a6005da + depends: + - ca-certificates + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.5,<4.0a0 + size: 9402647 + timestamp: 1791099870810 +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.15.2-h53f6dd8_100_cp315.conda + build_number: 100 + sha256: 75ce979442c5a5cd4f38ced72c5060c35e3b3d4539d92d2cb8bae5e3b6043fa4 + md5: 969c2999f8e4cc1da50ec1930cc7f3a5 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.5,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.54.1,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.9,<4.0a0 + - python_abi 3.15.* *_cp315 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.15.* *_cp315 + noarch: + - python + size: 18579162 + timestamp: 1793015037227 + python_site_packages_path: lib/python/site-packages +- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda + sha256: f19618a3a82cc483dacf1c70a30367ee7b0c7e71b90f1f80e14feff44fbd3688 + md5: dd9eb33c99d352b6356f86964d6040f7 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3782376 + timestamp: 1787272860855 +- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 + md5: 71b24316859acd00bdb8b38f5e2ce328 + constrains: + - vc14_runtime >=14.29.30037 + - vs2015_runtime >=14.29.30037 + license: LicenseRef-MicrosoftWindowsSDK10 + run_exports: {} + size: 694692 + timestamp: 1756385147981 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-hda273ae_43.conda + sha256: b021e65bf2240a3256639913aef12f14e63c396170a103285adecfbff9bb01ac + md5: 68ac6efe6c33bf56b73d3579692499c8 + depends: + - vc14_runtime >=14.51.36247 + license: BSD-3-Clause + run_exports: {} + size: 18006 + timestamp: 1794305234654 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36247-h072b54c_43.conda + sha256: 02e21ff2ff06c7e45e10b72953cb2b1739e8dad7a4a8a49c814897cfabefaada + md5: ca854408249855e70fb371a3b52fa083 + depends: + - vcomp14 ==14.51.36247 h1c3f1cb_43 + - ucrt >=10.0.20348.0 + constrains: + - vs2015_runtime 14.51.36247.* *_43 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + run_exports: {} + size: 840832 + timestamp: 1794305234654 +- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-h1c3f1cb_43.conda + sha256: 159fe1bb304d6a8e82a0e4b71762c05325cff3d472e4a1d49465c9def61a785e + md5: 872e2bfba57777676f0261c459176afe + depends: + - ucrt >=10.0.20348.0 + constrains: + - vs2015_runtime 14.51.36247.* *_43 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + run_exports: + strong: + - vcomp14 >=14.51.36247 + size: 166744 + timestamp: 1794305234654 +- conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda + sha256: ca7daae4f218a11fab82cc2857f0ea518ec3f46acec60490485347a4c22c6b3e + md5: e4ac308c39d6d0e131154976da67cf3b + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - libzlib >=1.3.2,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 387535 + timestamp: 1786599623274 diff --git a/autotest/release/pixi.toml b/autotest/release/pixi.toml new file mode 100644 index 00000000..db689766 --- /dev/null +++ b/autotest/release/pixi.toml @@ -0,0 +1,14 @@ +[workspace] +name = "hello" +channels = ["conda-forge"] +platforms = ["win-64", "linux-64", "osx-arm64"] +version = "0.1.0.dev0" + +[dependencies] +meson = ">=1.7.0" +ninja = "*" + +[tasks] +setup = "meson setup --prefix=$PIXI_PROJECT_ROOT --bindir=bin" +build = "meson install -C" +test = "meson test --verbose --no-rebuild -C builddir" diff --git a/autotest/release/release.toml b/autotest/release/release.toml new file mode 100644 index 00000000..dfee2a26 --- /dev/null +++ b/autotest/release/release.toml @@ -0,0 +1,4 @@ +[[version-files]] +path = "src/hello.f90" +pattern = "VERSION = '([^']*)'" +format = "{version} {date:%m/%d/%Y}" diff --git a/autotest/release/src/hello.f90 b/autotest/release/src/hello.f90 new file mode 100644 index 00000000..e6ae5f90 --- /dev/null +++ b/autotest/release/src/hello.f90 @@ -0,0 +1,17 @@ +! Print the kind of a default real and, given an expected kind as the first +! argument, fail when they differ. The version line is updated by +! scripts/version.py through release.toml. +program hello + implicit none + character(len=*), parameter :: VERSION = '0.1.0.dev0 01/01/2026' + character(len=16) :: arg + real :: x + integer :: expected + print '(a)', 'hello ' // VERSION + print '(a,i0)', 'real kind ', kind(x) + if (command_argument_count() > 0) then + call get_command_argument(1, arg) + read (arg, *) expected + if (expected /= kind(x)) error stop 'unexpected real kind' + end if +end program hello diff --git a/autotest/test_release.py b/autotest/test_release.py new file mode 100644 index 00000000..3365559f --- /dev/null +++ b/autotest/test_release.py @@ -0,0 +1,145 @@ +import shutil +import subprocess +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from modflow_devtools.release import changelog, version + +FIXTURE = Path(__file__).resolve().parent / "release" + + +@pytest.fixture +def repo(tmp_path): + root = tmp_path / "hello" + shutil.copytree(FIXTURE, root, ignore=shutil.ignore_patterns("builddir*", "bin", ".pixi")) + return root + + +def test_get(repo): + assert version.get_version(repo) == "0.1.0.dev0" + + +def test_set_updates_every_target(repo): + date = datetime(2026, 11, 10, tzinfo=UTC) + version.set_version(repo, "1.2.3", date=date) + assert version.get_version(repo) == "1.2.3" + assert "version: '1.2.3'" in (repo / "meson.build").read_text() + assert 'version = "1.2.3"' in (repo / "pixi.toml").read_text() + assert "VERSION = '1.2.3 11/10/2026'" in (repo / "src" / "hello.f90").read_text() + + +def test_set_keeps_line_endings(repo): + path = repo / "src" / "hello.f90" + path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) + version.set_version(repo, "1.2.3") + assert b"\r\n" in path.read_bytes() + assert b"\n" not in path.read_bytes().replace(b"\r\n", b"") + + +@pytest.mark.parametrize("value", ["1.2", "v1.2.3", "1.2.3rc1", "1.2.3.dev"]) +def test_set_rejects_bad_versions(repo, value): + with pytest.raises(SystemExit): + version.set_version(repo, value) + + +def test_next(): + assert version.next_version("1.5.0") == "1.6.0.dev0" + assert version.next_version("1.5.2") == "1.6.0.dev2" + assert version.next_version("1.6.0.dev0") == "1.7.0.dev0" + + +def test_is_release(repo): + assert version.main(["--root", str(repo), "--is-release"]) == 1 + version.set_version(repo, "1.0.0") + assert version.main(["--root", str(repo), "--is-release"]) == 0 + + +def test_version_txt(repo): + meson = repo / "meson.build" + meson.write_text( + meson.read_text().replace("version: '0.1.0.dev0'", "version: files('version.txt')") + ) + (repo / "version.txt").write_text("1.0.03\n") + assert version.get_version(repo) == "1.0.03" + version.set_version(repo, "1.0.4") + assert (repo / "version.txt").read_text() == "1.0.4\n" + + +def test_without_pixi_or_config(repo): + (repo / "pixi.toml").unlink() + (repo / "release.toml").unlink() + version.set_version(repo, "2.0.0") + assert version.get_version(repo) == "2.0.0" + + +def test_missing_target_fails(repo): + path = repo / "src" / "hello.f90" + path.write_text(path.read_text().replace("VERSION =", "V =")) + with pytest.raises(SystemExit): + version.set_version(repo, "1.2.3") + + +CHANGELOG = """\ +# Changelog + +## Version 1.2.0 (2026-11-10) + +### Features + +* Something new + +## Version 1.1.0 (2026-01-01) + +### Fixes + +* Something old +""" + + +def test_notes(tmp_path): + (tmp_path / "CHANGELOG.md").write_text(CHANGELOG) + assert changelog.notes(tmp_path, "1.2.0") == "### Features\n\n* Something new\n" + assert changelog.notes(tmp_path, "1.1.0") == "### Fixes\n\n* Something old\n" + with pytest.raises(SystemExit): + changelog.notes(tmp_path, "1.0.0") + + +def git(root, *args): + subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=root, + check=True, + capture_output=True, + ) + + +@pytest.mark.skipif(shutil.which("git-cliff") is None, reason="needs git-cliff") +def test_update(repo): + git(repo, "init", "-q", "-b", "main") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "feat: first") + git(repo, "tag", "v0.1.0") + git(repo, "commit", "-q", "--allow-empty", "-m", "fix(io): read the thing") + git(repo, "commit", "-q", "--allow-empty", "-m", "ci: skipped") + git(repo, "commit", "-q", "--allow-empty", "-m", "fix(ci): also skipped") + changelog.update(repo, "0.2.0", "MODFLOW-ORG/hello", "Hello") + text = (repo / "CHANGELOG.md").read_text() + assert "Features, fixes, and performance changes in each Hello release." in text + assert "## Version 0.2.0" in text + assert "io: Read the thing" in text + assert "https://github.com/MODFLOW-ORG/hello/commit/" in text + assert "first" not in text and "skipped" not in text + assert changelog.notes(repo, "0.2.0").startswith("### Fixes") + + # a second release prepends, keeping the first + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "chore(release): 0.2.0") + git(repo, "tag", "v0.2.0") + git(repo, "commit", "-q", "--allow-empty", "-m", "perf: faster") + changelog.update(repo, "0.3.0", "MODFLOW-ORG/hello", "Hello") + text = (repo / "CHANGELOG.md").read_text() + assert text.count("# Changelog") == 1 + assert text.index("## Version 0.3.0") < text.index("## Version 0.2.0") + assert "Faster" in changelog.notes(repo, "0.3.0") diff --git a/docs/examples/build.yml b/docs/examples/build.yml new file mode 100644 index 00000000..5a371149 --- /dev/null +++ b/docs/examples/build.yml @@ -0,0 +1,89 @@ +# Example build workflow for a program repo, e.g. mfusg. Copy it to +# .github/workflows/build.yml and adapt it. It runs on pushes, pull requests +# and a schedule, and is also called by the release workflow, which sets +# `ref` and `release`. Release builds must upload one archive per platform +# with the package action (`upload: true`). +name: build +on: + push: + pull_request: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + workflow_call: + inputs: + ref: + description: Branch or commit to build + type: string + default: "" + release: + description: Package and upload the release archives + type: boolean + default: false +# a build called by the release workflow mustn't share a group with (and get +# cancelled by) a regular build of the same push +concurrency: + group: build-${{ inputs.release }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true +env: + PIXI_BETA_WARNING_OFF: true +jobs: + build: + name: ${{ matrix.os }} ${{ matrix.compiler }} ${{ matrix.version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # release builds, one per platform + - {os: ubuntu-22.04, compiler: intel, version: "2025.0", release: true} + - {os: macos-15, compiler: gcc, version: "13", xcode-version: "16.4", release: true} + - {os: windows-2022, compiler: intel, version: "2025.0", release: true} + # extra coverage + - {os: ubuntu-22.04, compiler: gcc, version: "13"} + - {os: windows-2022, compiler: gcc, version: "13"} + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup + uses: MODFLOW-ORG/modflow-devtools/actions/setup@1.10.0 + with: + compiler: ${{ matrix.compiler }} + compiler-version: ${{ matrix.version }} + xcode-version: ${{ matrix.xcode-version }} + + - name: Build + run: | + pixi run setup builddir + pixi run build builddir + pixi run setup builddir-dble -Ddouble=true + pixi run build builddir-dble + + - name: Test + env: + GITHUB_TOKEN: ${{ github.token }} + run: pixi run test + + - name: Check linkage + uses: MODFLOW-ORG/modflow-devtools/actions/check-linkage@1.10.0 + + - name: Package + if: inputs.release && matrix.release + uses: MODFLOW-ORG/modflow-devtools/actions/package@1.10.0 + with: + name: mfusg + upload: true + + - name: Upload build logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: meson-logs-${{ matrix.os }}-${{ matrix.compiler }}-${{ matrix.version }} + path: builddir*/meson-logs/ + if-no-files-found: ignore diff --git a/docs/examples/release.yml b/docs/examples/release.yml new file mode 100644 index 00000000..24564cbb --- /dev/null +++ b/docs/examples/release.yml @@ -0,0 +1,69 @@ +# Example release workflow for a program repo, e.g. mfusg. Copy it to +# .github/workflows/release.yml and set the program name and long name, and +# develop-branch in both actions if development happens on a separate branch. +# It calls the repo's own build workflow (docs/examples/build.yml). +name: release +on: + workflow_dispatch: + inputs: + version: + description: Version to release, e.g. 1.5.2 + required: true + type: string + push: + branches: + - main +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + start: + name: Start release + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + stage: ${{ steps.start.outputs.stage }} + ref: ${{ steps.start.outputs.ref }} + version: ${{ steps.start.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Start release + id: start + uses: MODFLOW-ORG/modflow-devtools/actions/release-start@1.10.0 + with: + version: ${{ inputs.version }} + long-name: MODFLOW-USG + + build: + name: Build + needs: start + if: needs.start.outputs.stage != '' + uses: ./.github/workflows/build.yml + with: + ref: ${{ needs.start.outputs.ref }} + release: true + + finish: + name: Finish release + needs: + - start + - build + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Finish release + uses: MODFLOW-ORG/modflow-devtools/actions/release-finish@1.10.0 + with: + stage: ${{ needs.start.outputs.stage }} + ref: ${{ needs.start.outputs.ref }} + version: ${{ needs.start.outputs.version }} + name: mfusg + long-name: MODFLOW-USG diff --git a/docs/index.rst b/docs/index.rst index ec7f2fc1..606ab116 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,6 +41,7 @@ The `modflow-devtools` package provides a set of tools for developing and testin md/programs.md md/ostags.md + md/release.md .. toctree:: :maxdepth: 2 diff --git a/docs/md/release.md b/docs/md/release.md new file mode 100644 index 00000000..cc7a7e58 --- /dev/null +++ b/docs/md/release.md @@ -0,0 +1,212 @@ +# Release actions + +Shared CI and release tooling for MODFLOW program repositories. + + + + +- [Overview](#overview) +- [Requirements](#requirements) + - [Build system](#build-system) + - [Version](#version) + - [Commits](#commits) + - [Repository settings](#repository-settings) +- [Build workflow](#build-workflow) +- [Release workflow](#release-workflow) + - [Branching models](#branching-models) + - [Making a release](#making-a-release) +- [Release assets](#release-assets) +- [Actions](#actions) +- [Versioning](#versioning) +- [Testing](#testing) + + + +## Overview + +`modflow-devtools` provides + +- composite actions ([`actions/`](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/actions)) for building, checking, packaging and releasing programs +- the `modflow_devtools.release` modules the actions run, for versioning and changelog generation +- example workflows ([`docs/examples/`](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/docs/examples)) to copy into program repositories + +Each program repository owns its workflows. Builds and tests differ between programs, so the build workflow is the repository's own, and it's short since the involved parts (compiler setup, static runtime linking on macOS, linkage checks, packaging) are actions. The release procedure should be the same everywhere, so its logic lives in the `release-start` and `release-finish` actions, and a program's release workflow just wires them up around its build workflow. + +The actions are meant for the simpler program repos (mfusg, mfnwt, mt3d-usgs, etc). MODFLOW 6 has a more complex release procedure and keeps its own workflows, but can use some of the actions, and its releases should follow the same [asset conventions](#release-assets). Releases following the conventions can be installed with the [Programs API](programs.md). + +## Requirements + +A program repository must satisfy the following to use the actions. + +### Build system + +The program should build with [Meson](https://mesonbuild.com/) and [pixi](https://pixi.sh/). Programs in Fortran, C, or C++ are supported. `pixi.toml` must be at the repository root, and `pixi.lock` should be committed. + +How the program is built and tested is up to the repository's build workflow. The examples assume pixi tasks like + +```toml +[tasks] +setup = "meson setup --prefix=$PIXI_PROJECT_ROOT --bindir=bin" +build = "meson install -C" +test = "meson test --verbose --no-rebuild -C builddir" +``` + +Executables must install to `bin/` at the repository root. Everything in `bin/` goes in the release archives, so nothing else should be installed there. If the program has a double precision build, its executables must have different names than the single precision ones (e.g. `mfusg` and `mfusgdbl`). + +Don't set `link_language: 'fortran'` on executables. With Intel, it makes Meson append `-lifcore -limf` after `-static-intel`, so the executables link the Intel runtime dynamically. The `check-linkage` action catches this. + +Compilers are installed with [setup-fortran](https://github.com/fortran-lang/setup-fortran), which also sets `CC` and `CXX`. Releases should be built with Intel `ifx` on Linux and Windows, and with GNU Fortran on macOS. + +### Version + +The version must be set in `meson.build`, either directly + +```meson +project( + 'mfusg', + 'fortran', + version: '1.6.0.dev0', + ... +``` + +or read from a file with `version: files('version.txt')`. + +Release versions are `major.minor.patch`. Between releases, the repository holds the next minor version with a `.devN` suffix, where `N` is the last release's patch number as in MODFLOW 6, e.g. `1.6.0.dev0` after `1.5.0` and `1.6.0.dev2` after `1.5.2`. Programs with `major.minor` versions should switch to `major.minor.0` at their next release. + +If `pixi.toml` has a `version`, it is kept in sync with `meson.build`. Other files containing the version, e.g. a version string in the source code, can be listed in `release.toml` at the repository root: + +```toml +[[version-files]] +path = "src/mfusg.f" +# exactly one capture group, matching the text to replace +pattern = "PARAMETER \\(VERSION='([^']*)'\\)" +# optional, {version} and {date} (UTC) are available +format = "{version} {date:%m/%d/%Y}" +``` + +To get or set the version locally: + +```shell +python -m modflow_devtools.release.version --root ../mfusg --get +python -m modflow_devtools.release.version --root ../mfusg --set 1.6.0.dev0 +``` + +### Commits + +Release notes are generated with [git-cliff](https://git-cliff.org/) from [conventional commits](https://www.conventionalcommits.org/) since the last release. Only `feat`, `fix` and `perf` commits are included, and commits scoped `ci` (e.g. `fix(ci)`) are skipped. + +The repository may provide its own `cliff.toml`, otherwise the [shared one](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/modflow_devtools/release/cliff.toml) is used. If there is no `CHANGELOG.md`, one is created at the first release. + +### Repository settings + +The release actions create pull requests with the workflow token. This must be enabled under *Settings* -> *Actions* -> *General* -> *Allow GitHub Actions to create and approve pull requests*. + +Program repositories should also enable [dependabot](https://docs.github.com/en/code-security/dependabot) for GitHub Actions, since their workflows pin action versions. + +## Build workflow + +Copy [`docs/examples/build.yml`](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/docs/examples/build.yml) to `.github/workflows/build.yml` and adapt it. It runs on pushes, pull requests and a weekly schedule, and on each matrix entry + +- checks out the repository +- sets up the compiler and pixi with the `setup` action +- builds single and double precision +- runs the tests +- checks the executables' linkage with the `check-linkage` action + +The build workflow must also accept `workflow_call` with inputs `ref` (what to check out) and `release` (whether to upload archives), since the release workflow calls it. In a release build, one job per platform must run the `package` action with `upload: true`. The example marks those matrix entries `release: true`. + +## Release workflow + +Copy [`docs/examples/release.yml`](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/docs/examples/release.yml) to `.github/workflows/release.yml` and set the program's name and long name. It has three jobs: + +- `start`: the `release-start` action decides what to do, see [Making a release](#making-a-release) +- `build`: calls the repository's build workflow on the release branch or commit +- `finish`: the `release-finish` action opens the release pull request, or publishes the release + +`release-start` inputs: + +- `version`: pass `${{ inputs.version }}` +- `long-name`: program long name, used in the shared changelog header (required) +- `main-branch`: branch releases are merged into and tagged on (default `main`) +- `develop-branch`: development branch, if not the main branch, see [Branching models](#branching-models) + +`release-finish` inputs: + +- `stage`, `ref`, `version`: pass `release-start`'s outputs +- `name`: program name, used for the source archive and release title (required) +- `long-name`: program long name, used in the release notes (required) +- `main-branch`, `develop-branch`: as for `release-start` +- `platforms`: OS tags that must each have an archive (default `linux macarm win64`) + +### Branching models + +Two branching models are supported: + +- **single**: development and releases both happen on `main`. Release branches are created from and merged back into `main`. This is the default. +- **dual**: development happens on `develop`, as in MODFLOW 6. Release branches are created from `develop` and merged into `main`, where the release is tagged. `main` is then merged back into `develop`. Set `develop-branch: develop` on both release actions to use this model. + +In the dual model, the pull request that sets the next development version also merges `main` back into `develop`. It must be merged with a merge commit, not squashed, so the release tag is reachable from `develop`. Otherwise the next release's notes will repeat this release's changes. + +### Making a release + +To make a release: + +1. Run the release workflow from the development branch (*Actions* -> *release* -> *Run workflow*) with the version to release, e.g. `1.5.2`. The workflow + - creates branch `release/v1.5.2` + - sets the version + - updates `CHANGELOG.md` + - builds and tests on each platform + - opens a pull request into `main` +2. Review the pull request. The version's section in `CHANGELOG.md` becomes the release notes, so edit it on the release branch if needed. +3. Merge the pull request. If the version on `main` is a release version without a tag, the workflow + - builds and tests on each platform again + - publishes release `v1.5.2` with the archives, targeting the merge commit + - opens a pull request setting the next development version (e.g. `1.6.0.dev2`) on the development branch +4. Merge that pull request. + +On any other push to `main`, `release-start` finds nothing to do and the other jobs are skipped. + +**Note:** pull requests created with the workflow token don't trigger other workflows, so the build workflow doesn't run on them. The release workflow already built and tested the release branch in step 1. To run the build workflow anyway, close and reopen the pull request. + +## Release assets + +Program releases share a common layout, so the [executables](https://github.com/MODFLOW-ORG/executables) distribution and [modflow-devtools](https://github.com/MODFLOW-ORG/modflow-devtools) can treat every program the same way: + +- tag `vX.Y.Z` +- one archive per platform, named for the [OS tag](ostags.md): `linux.zip`, `macarm.zip`, and `win64.zip` +- executables at the archive root, no enclosing directory, double precision executables included if any +- optionally, `_source.zip` +- no dynamic linking to compiler runtime libraries, i.e. Intel's on Linux or GNU's on macOS + +The `package` action produces this layout, and `release-finish` refuses to publish missing or unexpected archives. + +Intel macOS (`mac`) is no longer supported. + +## Actions + +- `actions/setup`: set up the compiler and pixi, and on macOS, Xcode and static GCC runtime linking +- `actions/check-linkage`: list each executable's shared library dependencies and fail on compiler runtime libraries, and on macOS, check for arm64 +- `actions/package`: zip `bin/` as `.zip`, and on Linux the source as `_source.zip`, optionally uploading them as artifact `dist-` +- `actions/release-start`: prepare the release branch, or check whether `main` needs releasing +- `actions/release-finish`: open the release pull request, or publish the release and open the next version pull request +- `actions/version`: get or set the version, or get the next development version +- `actions/changelog`: update `CHANGELOG.md` with git-cliff, or get a version's release notes + +See each `action.yml` for inputs and outputs. + +## Versioning + +The actions are versioned with `modflow-devtools`. Program repositories reference them at a release tag, e.g. `MODFLOW-ORG/modflow-devtools/actions/setup@1.10.0`, and dependabot proposes updates with each `modflow-devtools` release. Breaking changes to the actions (e.g. renamed or removed inputs, or a different asset layout) follow the package's versioning. + +## Testing + +`.github/workflows/actions.yml` runs the script tests, lints the example workflows, and runs the actions from the commit under test on a small Fortran program in `autotest/release` on each release platform. It runs only on changes to the actions, the scripts, the fixture or the examples. + +To run the script tests locally: + +```shell +cd autotest +pytest test_release.py +``` + +The changelog tests require `git-cliff` and are skipped if it's not installed. diff --git a/modflow_devtools/release/__init__.py b/modflow_devtools/release/__init__.py new file mode 100644 index 00000000..dc0bbf90 --- /dev/null +++ b/modflow_devtools/release/__init__.py @@ -0,0 +1,3 @@ +"""Release tooling for MODFLOW program repositories, used by the composite +actions in ``actions/``. The modules only use the standard library, so the +actions run them as scripts without installing the package.""" diff --git a/modflow_devtools/release/changelog.py b/modflow_devtools/release/changelog.py new file mode 100644 index 00000000..b2df6f38 --- /dev/null +++ b/modflow_devtools/release/changelog.py @@ -0,0 +1,90 @@ +""" +Update CHANGELOG.md for a release, or print a release's notes from it. + +``update`` runs git-cliff with the repo's cliff.toml, or with the shared +template (cliff.toml beside this script) filled in with the +repository and the program's long name. ``notes`` prints the section of +CHANGELOG.md for one version, which becomes the GitHub release notes. +""" + +import argparse +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +TEMPLATE = Path(__file__).resolve().parent / "cliff.toml" +HEADING = re.compile(r"^## Version (\S+)") + + +def config(root, repository, long_name): + own = root / "cliff.toml" + if own.is_file(): + return own + text = TEMPLATE.read_text() + text = text.replace("__REPOSITORY__", repository).replace("__NAME__", long_name) + tmp = Path(tempfile.mkdtemp()) / "cliff.toml" + tmp.write_text(text) + return tmp + + +def update(root, version, repository, long_name): + cliff = shutil.which("git-cliff") + if cliff is None: + sys.exit("git-cliff is not installed") + changelog = root / "CHANGELOG.md" + cmd = [ + cliff, + "--config", + str(config(root, repository, long_name)), + "--unreleased", + "--tag", + f"v{version}", + ] + # --prepend keeps the existing file's header and earlier sections + cmd += ["--prepend", str(changelog)] if changelog.is_file() else ["-o", str(changelog)] + subprocess.run(cmd, cwd=root, check=True) + + +def notes(root, version): + changelog = root / "CHANGELOG.md" + if not changelog.is_file(): + sys.exit("no CHANGELOG.md") + lines, found = [], False + for line in changelog.read_text().splitlines(): + match = HEADING.match(line) + if match: + if found: + break + found = match.group(1) == version + continue + if found: + lines.append(line) + if not found: + sys.exit(f"no section for version {version} in CHANGELOG.md") + return "\n".join(lines).strip() + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="repo root") + sub = parser.add_subparsers(dest="command", required=True) + up = sub.add_parser("update", help="prepend this release to CHANGELOG.md") + up.add_argument("--version", required=True) + up.add_argument("--repository", required=True, help="owner/repo") + up.add_argument("--long-name", required=True) + no = sub.add_parser("notes", help="print one version's section") + no.add_argument("--version", required=True) + args = parser.parse_args(argv) + root = args.root.resolve() + if args.command == "update": + update(root, args.version, args.repository, args.long_name) + else: + sys.stdout.write(notes(root, args.version)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modflow_devtools/release/cliff.toml b/modflow_devtools/release/cliff.toml new file mode 100644 index 00000000..29d4d791 --- /dev/null +++ b/modflow_devtools/release/cliff.toml @@ -0,0 +1,41 @@ +# Shared git-cliff template for CHANGELOG.md and the release notes. Used when +# a program repo has no cliff.toml of its own; changelog.py fills in +# __REPOSITORY__ (owner/repo) and __NAME__ (the program's long name). Only +# features, fixes, and performance changes are listed. + +[changelog] +header = """ +# Changelog + +Features, fixes, and performance changes in each __NAME__ release.\n +""" +body = """ +{% if version %}\ + ## Version {{ version | trim_start_matches(pat="v") }} ({{ timestamp | date(format="%Y-%m-%d") }}) +{% else %}\ + ## Unreleased +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + * {% if commit.scope %}{{ commit.scope }}: {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/__REPOSITORY__/commit/{{ commit.id }}))\ + {% endfor %} +{% endfor %}\n +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_parsers = [ + { message = "^[a-zA-Z]+\\(ci\\)", skip = true }, + { message = "^[fF]eat", group = "Features" }, + { message = "^[fF]ix", group = "Fixes" }, + { message = "^[pP]erf", group = "Performance" }, + { message = ".*", skip = true }, +] +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+" +sort_commits = "oldest" diff --git a/modflow_devtools/release/version.py b/modflow_devtools/release/version.py new file mode 100644 index 00000000..3390ddcf --- /dev/null +++ b/modflow_devtools/release/version.py @@ -0,0 +1,149 @@ +""" +Get or set a program's version. + +The version lives in meson.build (``version: 'X.Y.Z'``), or in version.txt +when meson.build reads it with ``version: files('version.txt')``. Setting the +version also updates ``version = "..."`` in pixi.toml, when present, and any +extra files listed in release.toml: + + [[version-files]] + path = "src/mfusg.f" + # one capture group, holding the text to replace + pattern = "PARAMETER \\(VERSION='([^']*)'\\)" + # optional; {version} and {date} (UTC) are available + format = "{version} {date:%m/%d/%Y}" + +Release versions are X.Y.Z, development versions X.Y.Z.devN. +""" + +import argparse +import re +import sys +import tomllib +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +RELEASE = re.compile(r"^\d+\.\d+\.\d+$") +DEVELOP = re.compile(r"^\d+\.\d+\.\d+\.dev\d+$") + +MESON_LITERAL = re.compile(r"^\s*version\s*:\s*'([^']*)'", re.M) +MESON_FILE = re.compile(r"^\s*version\s*:\s*files\(\s*'([^']*)'\s*\)", re.M) +PIXI = re.compile(r'^version\s*=\s*"([^"]*)"', re.M) + + +@dataclass +class Target: + path: Path + pattern: re.Pattern + format: str = "{version}" + + +def read(path): + with Path(path).open(newline="") as f: + return f.read() + + +def write(path, text): + with Path(path).open("w", newline="") as f: + f.write(text) + + +def source(root): + """The file that holds the version, and a pattern matching it.""" + meson = root / "meson.build" + if not meson.is_file(): + sys.exit(f"no meson.build in {root}") + text = read(meson) + match = MESON_FILE.search(text) + if match: + return Target(root / match.group(1), re.compile(r"\A\s*(\S+)\s*\Z")) + if MESON_LITERAL.search(text): + return Target(meson, MESON_LITERAL) + sys.exit("no version in meson.build") + + +def load_config(root): + path = root / "release.toml" + if not path.is_file(): + return {} + with path.open("rb") as f: + return tomllib.load(f) + + +def targets(root): + """Every file the version is written to, the source first.""" + found = [source(root)] + pixi = root / "pixi.toml" + if pixi.is_file() and PIXI.search(read(pixi)): + found.append(Target(pixi, PIXI)) + for entry in load_config(root).get("version-files", []): + pattern = re.compile(entry["pattern"], re.M) + if pattern.groups != 1: + sys.exit(f"pattern for {entry['path']} needs exactly one group") + found.append(Target(root / entry["path"], pattern, entry.get("format", "{version}"))) + return found + + +def get_version(root): + target = source(root) + match = target.pattern.search(read(target.path)) + if match is None: + sys.exit(f"no version in {target.path.name}") + return match.group(1) + + +def next_version(version): + """The next minor development version. The dev number is the micro + version, as in MODFLOW 6, so 1.5.0 gives 1.6.0.dev0 and 1.5.2 gives + 1.6.0.dev2.""" + major, minor, micro = version.split(".")[:3] + return f"{major}.{int(minor) + 1}.0.dev{int(micro)}" + + +def set_version(root, version, date=None): + if not (RELEASE.match(version) or DEVELOP.match(version)): + sys.exit(f"{version} is not X.Y.Z or X.Y.Z.devN") + date = date or datetime.now(UTC) + for target in targets(root): + text = read(target.path) + matches = list(target.pattern.finditer(text)) + if len(matches) != 1: + name = target.path.relative_to(root) + sys.exit(f"expected one version in {name}, found {len(matches)}") + match = matches[0] + value = target.format.format(version=version, date=date) + start, end = match.span(1) + write(target.path, text[:start] + value + text[end:]) + print(f"{target.path.relative_to(root).as_posix()}: {value}") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="repo root") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--get", action="store_true", help="print the version") + group.add_argument("--next", action="store_true", help="print the next minor dev version") + group.add_argument( + "--is-release", + action="store_true", + help="exit 0 if the version is X.Y.Z, else 1", + ) + group.add_argument("--set", metavar="VERSION", help="set the version") + args = parser.parse_args(argv) + root = args.root.resolve() + if args.get: + print(get_version(root)) + elif args.next: + print(next_version(get_version(root))) + elif args.is_release: + return 0 if RELEASE.match(get_version(root)) else 1 + else: + set_version(root, args.set) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index e23de0f0..c351abe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ core-metadata-version = "2.4" [tool.hatch.build] include = [ "modflow_devtools/dfn/*.toml", + "modflow_devtools/release/cliff.toml", "modflow_devtools/registry/*" ] From cf031f5d710bc6aa786088b81bf73f317cb36456 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 11 Nov 2026 06:12:35 -0800 Subject: [PATCH 2/4] fix(ci): expand paths alias in release actions workflow actionlint 1.7.7 rejects YAML aliases for on..paths. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/actions.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 729c1377..5cd23c5e 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -5,7 +5,7 @@ name: Release actions on: push: - paths: &paths + paths: - 'actions/**' - 'modflow_devtools/release/**' - 'autotest/release/**' @@ -16,7 +16,13 @@ on: branches: - main - develop - paths: *paths + paths: + - 'actions/**' + - 'modflow_devtools/release/**' + - 'autotest/release/**' + - 'autotest/test_release.py' + - 'docs/examples/**' + - '.github/workflows/actions.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 6981d80b968666c9bf2db21a137d003b99113c4d Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 15 Nov 2026 06:36:40 -0800 Subject: [PATCH 3/4] refactor(release): standardize and harden the release actions Move the release-start/finish logic into modflow_devtools/release/workflow.py so it can be tested, and make each stage safe to rerun. Fail before the build when a release has no changelog section. Support extra release assets, drop Intel macOS, take gfortran from pixi and always link compiler runtimes statically, enforce static linkage on every platform, package the same way everywhere, use only the shared git-cliff template at a pinned version, scope the meson version to project(), and update version files atomically. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/actions.yml | 20 +- actions/changelog/action.yml | 30 +- actions/check-linkage/action.yml | 64 +- actions/package/action.yml | 91 +-- actions/release-finish/action.yml | 123 +--- actions/release-start/action.yml | 95 +-- actions/setup/action.yml | 81 ++- actions/version/action.yml | 15 +- autotest/release/meson.build | 8 +- autotest/release/pixi.lock | 945 ++++++++++++++++++++++++++ autotest/release/pixi.toml | 1 + autotest/release/src/hello.f90 | 2 +- autotest/test_release.py | 422 +++++++++++- docs/examples/build.yml | 13 +- docs/examples/release.yml | 3 + docs/md/release.md | 75 +- modflow_devtools/release/__init__.py | 3 +- modflow_devtools/release/changelog.py | 60 +- modflow_devtools/release/cliff.toml | 9 +- modflow_devtools/release/package.py | 106 +++ modflow_devtools/release/version.py | 103 ++- modflow_devtools/release/workflow.py | 410 +++++++++++ 22 files changed, 2257 insertions(+), 422 deletions(-) create mode 100644 modflow_devtools/release/package.py create mode 100644 modflow_devtools/release/workflow.py diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 5cd23c5e..4e421082 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -1,7 +1,8 @@ -# Tests for the release actions (actions/) and scripts -# (modflow_devtools/release/): the scripts, the workflow syntax, and the -# composite actions against a small Fortran program (autotest/release) on each -# released platform. +# Tests for the release actions (actions/) and modules +# (modflow_devtools/release/): the modules, including the release workflow's +# stages against a local git remote and a fake gh, the workflow syntax, and +# the composite actions against a small Fortran program (autotest/release) on +# each released platform. name: Release actions on: push: @@ -40,10 +41,9 @@ jobs: - name: Setup uv uses: astral-sh/setup-uv@v7 + # the changelog tests run git-cliff with pipx - name: Install - run: | - uv sync --group test - pipx install git-cliff==2.14.2 + run: uv sync --group test - name: Test working-directory: autotest @@ -97,10 +97,10 @@ jobs: matrix: include: - {os: ubuntu-22.04, compiler: intel, version: "2025.0"} - - {os: macos-15, compiler: gcc, version: "13", xcode-version: "16.4"} + - {os: macos-15, compiler: gcc, xcode-version: "16.4"} - {os: windows-2022, compiler: intel, version: "2025.0"} - - {os: ubuntu-22.04, compiler: gcc, version: "13"} - - {os: windows-2022, compiler: gcc, version: "13"} + - {os: ubuntu-22.04, compiler: gcc} + - {os: windows-2022, compiler: gcc} steps: - name: Checkout uses: actions/checkout@v7 diff --git a/actions/changelog/action.yml b/actions/changelog/action.yml index bad61858..9d88cf81 100644 --- a/actions/changelog/action.yml +++ b/actions/changelog/action.yml @@ -1,8 +1,9 @@ name: Changelog description: > Prepend a release's features, fixes and performance changes to - CHANGELOG.md with git-cliff (command `update`; needs full git history), or - write a release's section of CHANGELOG.md to a notes file (command `notes`). + CHANGELOG.md with git-cliff and the shared template (command `update`; + needs full git history), or write a release's section of CHANGELOG.md to a + notes file (command `notes`). inputs: path: description: Directory of the program repo checkout @@ -14,11 +15,8 @@ inputs: description: Release version, X.Y.Z required: true long-name: - description: Program long name, e.g. MODFLOW-USG; used for the shared template and the notes title + description: Program long name, e.g. MODFLOW-USG; used in the changelog header and the notes title required: true - git-cliff-version: - description: git-cliff version - default: 2.14.2 outputs: notes-file: description: Path of the notes file (command `notes`) @@ -33,25 +31,18 @@ runs: python-version: "3.12" update-environment: false - - name: Install git-cliff - if: inputs.command == 'update' - shell: bash - env: - GIT_CLIFF_VERSION: ${{ inputs.git-cliff-version }} - run: pipx install "git-cliff==$GIT_CLIFF_VERSION" - - name: Update CHANGELOG.md if: inputs.command == 'update' shell: bash working-directory: ${{ inputs.path }} env: - SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/changelog.py PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. VERSION: ${{ inputs.version }} LONG_NAME: ${{ inputs.long-name }} REPOSITORY: ${{ github.repository }} run: | - "$PYTHON" "$SCRIPT" update --version "$VERSION" \ + "$PYTHON" -P -m modflow_devtools.release.changelog update --version "$VERSION" \ --repository "$REPOSITORY" --long-name "$LONG_NAME" - name: Write release notes @@ -60,16 +51,13 @@ runs: shell: bash working-directory: ${{ inputs.path }} env: - SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/changelog.py PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. VERSION: ${{ inputs.version }} LONG_NAME: ${{ inputs.long-name }} run: | file="$RUNNER_TEMP/notes.md" - { - echo "$LONG_NAME version $VERSION" - echo - "$PYTHON" "$SCRIPT" notes --version "$VERSION" - } > "$file" + "$PYTHON" -P -m modflow_devtools.release.changelog notes --version "$VERSION" \ + --long-name "$LONG_NAME" > "$file" cat "$file" echo "file=$file" >> "$GITHUB_OUTPUT" diff --git a/actions/check-linkage/action.yml b/actions/check-linkage/action.yml index 4b13bb4f..d1ffc01b 100644 --- a/actions/check-linkage/action.yml +++ b/actions/check-linkage/action.yml @@ -1,62 +1,58 @@ name: Check linkage description: > - List the shared libraries every executable in a directory links, and fail - if any links a compiler runtime that users would not have: the Intel - runtime on Linux, or the gcc/gfortran runtime on macOS. On macOS, also - require the expected architecture. Lists dependencies on Windows when - objdump is available, without failing. + List the shared libraries each executable and shared library in a + directory links, and fail if any links a compiler runtime library that + users might not have: the Intel, GNU (gfortran, quadmath, OpenMP, and on + macOS and Windows the gcc and C++ runtimes), or Microsoft Visual C++ + runtime. Linux's libgcc_s and libstdc++ are system libraries, so they're + allowed there. On macOS, also require arm64. inputs: path: description: Directory holding the executables default: bin - macos-arch: - description: Architecture every macOS executable must contain - default: arm64 runs: using: composite steps: - name: Check linkage shell: bash working-directory: ${{ inputs.path }} - env: - ARCH: ${{ inputs.macos-arch }} run: | + intel='libifcore|libifport|libimf|libsvml|libintlc|libirng|libiomp5|libmmd|svml_disp' + gnu='libgfortran|libquadmath|libgomp' status=0 + check() { + local exe="$1" deps="$2" forbidden="$3" + echo "::group::$exe"; echo "$deps"; echo "::endgroup::" + if bad=$(grep -iE "$forbidden" <<< "$deps"); then + echo "::error::$exe dynamically links a compiler runtime library: $(tr '\n' ' ' <<< "$bad")" + status=1 + fi + } case "$RUNNER_OS" in Linux) - forbidden='libifcore|libifport|libimf|libsvml|libintlc|libirng' - for exe in $(find . -maxdepth 1 -type f -perm -u+x | sort); do - echo "::group::$exe"; ldd "$exe" || true; echo "::endgroup::" - if ldd "$exe" | grep -E "$forbidden"; then - echo "::error::$exe dynamically links the Intel runtime" - status=1 - fi + for exe in $(find . -maxdepth 1 -type f \( -perm -u+x -o -name '*.so*' \) | sort); do + check "$exe" "$(ldd "$exe" 2>&1 || true)" "$intel|$gnu" done ;; macOS) - forbidden='libgfortran|libgcc_s|libquadmath|/libstdc\+\+' - for exe in $(find . -maxdepth 1 -type f -perm -u+x | sort); do - echo "::group::$exe"; otool -L "$exe"; lipo -info "$exe"; echo "::endgroup::" - if otool -L "$exe" | grep -E "$forbidden"; then - echo "::error::$exe dynamically links the gcc runtime" - status=1 - fi - if ! lipo -archs "$exe" | grep -qw "$ARCH"; then - echo "::error::$exe is not built for $ARCH" + for exe in $(find . -maxdepth 1 -type f \( -perm -u+x -o -name '*.dylib' \) | sort); do + check "$exe" "$(otool -L "$exe")" "$intel|$gnu|libgcc_s|/libstdc\+\+" + if ! lipo -archs "$exe" | grep -qw arm64; then + echo "::error::$exe is not built for arm64" status=1 fi done ;; Windows) - if command -v objdump >/dev/null; then - for exe in *.exe; do - echo "::group::$exe" - objdump -p "$exe" | grep -i 'DLL Name' || true - echo "::endgroup::" - done - else - echo "objdump not found, not listing dependencies" + if ! command -v objdump >/dev/null; then + echo "::error::objdump not found, can't check linkage" + exit 1 fi + shopt -s nullglob + for exe in *.exe *.dll; do + check "$exe" "$(objdump -p "$exe" | grep -i 'DLL Name' || true)" \ + "$intel|$gnu|libgcc_s|libstdc\+\+|libwinpthread|vcruntime|msvcp" + done ;; esac exit $status diff --git a/actions/package/action.yml b/actions/package/action.yml index b02ca62c..8ebb3a6e 100644 --- a/actions/package/action.yml +++ b/actions/package/action.yml @@ -1,10 +1,11 @@ name: Package a MODFLOW program description: > - Zip the executables in bin/ as .zip (linux, mac, macarm or win64), - with the files at the archive root, and on Linux also zip the source as - _source.zip. Outputs the directory holding the archives, and - optionally uploads them as artifact dist- for the release-finish - action. + Zip everything in bin/ as .zip (linux, macarm or win64), with the + files at the archive root, and on Linux also zip the source as + _source.zip. The archives are made the same way on every platform, + with modflow_devtools/release/package.py. Outputs the directory holding + the archives, and optionally uploads them as artifact dist- for the + release-finish action. inputs: path: description: Directory of the program repo checkout @@ -13,8 +14,10 @@ inputs: description: Program name, used for the source archive required: true source-paths: - description: Space-separated paths to put in the source archive; empty for none - default: meson.build meson.options src + description: > + Space-separated paths to put in the source archive, or `none` to skip + it; by default meson.build, meson.options (if present) and src + default: "" upload: description: > Upload the archives as artifact dist-; do this from one job per @@ -23,69 +26,47 @@ inputs: outputs: ostag: description: The platform tag - value: ${{ steps.ostag.outputs.ostag }} + value: ${{ steps.package.outputs.ostag }} dist: description: Directory holding the archives - value: ${{ steps.ostag.outputs.dist }} + value: ${{ steps.package.outputs.dist }} runs: using: composite steps: - - name: Set ostag - id: ostag - shell: bash - working-directory: ${{ inputs.path }} - run: | - case "$RUNNER_OS-$RUNNER_ARCH" in - Linux-X64) ostag=linux ;; - macOS-ARM64) ostag=macarm ;; - macOS-X64) ostag=mac ;; - Windows-X64) ostag=win64 ;; - *) echo "::error::no ostag for $RUNNER_OS $RUNNER_ARCH"; exit 1 ;; - esac - dist="$RUNNER_TEMP/dist" - mkdir -p "$dist" - echo "ostag=$ostag" >> "$GITHUB_OUTPUT" - echo "dist=$dist" >> "$GITHUB_OUTPUT" - - - name: Zip executables - if: runner.os != 'Windows' - shell: bash - working-directory: ${{ inputs.path }} - env: - OSTAG: ${{ steps.ostag.outputs.ostag }} - DIST: ${{ steps.ostag.outputs.dist }} - run: | - zip -r -j "$DIST/$OSTAG.zip" bin/ - unzip -l "$DIST/$OSTAG.zip" - - - name: Zip executables (Windows) - if: runner.os == 'Windows' - shell: pwsh - working-directory: ${{ inputs.path }} - env: - OSTAG: ${{ steps.ostag.outputs.ostag }} - DIST: ${{ steps.ostag.outputs.dist }} - run: | - Compress-Archive -Path "bin\*" -DestinationPath "$env:DIST\$env:OSTAG.zip" - Get-ChildItem $env:DIST + - name: Setup Python + id: python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + update-environment: false - - name: Zip source - if: runner.os == 'Linux' && inputs.source-paths != '' + - name: Zip archives + id: package shell: bash working-directory: ${{ inputs.path }} env: + PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. NAME: ${{ inputs.name }} SOURCE_PATHS: ${{ inputs.source-paths }} - DIST: ${{ steps.ostag.outputs.dist }} run: | - # shellcheck disable=SC2086 - zip -r "$DIST/${NAME}_source.zip" $SOURCE_PATHS - ls -l "$DIST" + dist="$RUNNER_TEMP/dist" + args=(--name "$NAME" --dist "$dist") + if [[ "$SOURCE_PATHS" == "none" ]]; then + args+=(--source-paths) + elif [[ -n "$SOURCE_PATHS" ]]; then + read -ra paths <<< "$SOURCE_PATHS" + args+=(--source-paths "${paths[@]}") + fi + ostag=$("$PYTHON" -P -m modflow_devtools.release.package "${args[@]}") + ls -l "$dist" + echo "ostag=$ostag" >> "$GITHUB_OUTPUT" + echo "dist=$dist" >> "$GITHUB_OUTPUT" - name: Upload archives if: inputs.upload == 'true' uses: actions/upload-artifact@v7 with: - name: dist-${{ steps.ostag.outputs.ostag }} - path: ${{ steps.ostag.outputs.dist }}/*.zip + name: dist-${{ steps.package.outputs.ostag }} + path: ${{ steps.package.outputs.dist }}/*.zip if-no-files-found: error diff --git a/actions/release-finish/action.yml b/actions/release-finish/action.yml index 08201d36..88d92dcf 100644 --- a/actions/release-finish/action.yml +++ b/actions/release-finish/action.yml @@ -3,9 +3,11 @@ description: > Last step of a release workflow, after the release-start action and the build. For stage `prepare`, open the release pull request into the main branch. For stage `publish`, download the dist-* artifacts uploaded by the - package action, check them, publish the GitHub release, and open a pull - request setting the next development version on the development branch. - Expects a checkout of the commit being released. + package action, check them, publish the GitHub release with them and any + extra assets, and open a pull request setting the next development version + on the development branch. Steps already done by an earlier run are + skipped, so a failed run can be rerun. Expects a checkout of the commit + being released. See modflow_devtools/release/workflow.py. inputs: path: description: Directory of the program repo checkout @@ -34,38 +36,19 @@ inputs: platforms: description: Space-separated OS tags that must each have an archive default: linux macarm win64 + extra-assets: + description: > + Newline-separated globs, relative to the checkout, of other files to + attach to the release, e.g. documentation. Each must match a file. + Files built in other jobs can be downloaded into the checkout first. + default: "" github-token: description: Token for creating the release and pull requests default: ${{ github.token }} runs: using: composite steps: - - name: Check stage - shell: bash - env: - STAGE: ${{ inputs.stage }} - run: | - if [[ "$STAGE" != "prepare" && "$STAGE" != "publish" ]]; then - echo "::error::Unexpected stage '$STAGE', expected prepare or publish." - exit 1 - fi - - - name: Open release pull request - if: inputs.stage == 'prepare' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - BRANCH: ${{ inputs.ref }} - VERSION: ${{ inputs.version }} - MAIN_BRANCH: ${{ inputs.main-branch }} - DEVELOP_BRANCH: ${{ inputs.develop-branch || inputs.main-branch }} - run: | - body="Review the $VERSION section of CHANGELOG.md, it becomes the release notes. Merging this publishes release v$VERSION and opens a pull request setting the next development version on $DEVELOP_BRANCH." - gh pr create -R "$GITHUB_REPOSITORY" -B "$MAIN_BRANCH" -H "$BRANCH" \ - --title "chore(release): $VERSION" --body "$body" - - name: Setup Python - if: inputs.stage == 'publish' id: python uses: actions/setup-python@v5 with: @@ -80,80 +63,32 @@ runs: merge-multiple: true path: ${{ runner.temp }}/dist - - name: Check archives - if: inputs.stage == 'publish' - shell: bash - env: - DIST: ${{ runner.temp }}/dist - NAME: ${{ inputs.name }} - PLATFORMS: ${{ inputs.platforms }} - run: | - ls -l "$DIST" - status=0 - for ostag in $PLATFORMS; do - if [[ ! -f "$DIST/$ostag.zip" ]]; then - echo "::error::Missing $ostag.zip." - status=1 - fi - done - for file in "$DIST"/*; do - base=$(basename "$file") - case "$base" in - linux.zip|mac.zip|macarm.zip|win64.zip|"${NAME}_source.zip") ;; - *) echo "::error::Unexpected file $base."; status=1 ;; - esac - done - exit $status - - - name: Publish release - if: inputs.stage == 'publish' + - name: Finish release shell: bash working-directory: ${{ inputs.path }} env: GH_TOKEN: ${{ inputs.github-token }} - SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/changelog.py PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. DIST: ${{ runner.temp }}/dist - NOTES: ${{ runner.temp }}/notes.md - VERSION: ${{ inputs.version }} + STAGE: ${{ inputs.stage }} REF: ${{ inputs.ref }} + VERSION: ${{ inputs.version }} NAME: ${{ inputs.name }} LONG_NAME: ${{ inputs.long-name }} - run: | - { - echo "$LONG_NAME version $VERSION" - echo - "$PYTHON" "$SCRIPT" notes --version "$VERSION" - } > "$NOTES" - cat "$NOTES" - gh release create "v$VERSION" "$DIST"/* -R "$GITHUB_REPOSITORY" \ - --target "$REF" --title "$NAME $VERSION" --notes-file "$NOTES" --latest - - # in the dual-branch model this also merges the release back into the - # development branch, so it must be merged with a merge commit - - name: Open next version pull request - if: inputs.stage == 'publish' - shell: bash - working-directory: ${{ inputs.path }} - env: - GH_TOKEN: ${{ inputs.github-token }} - SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/version.py - PYTHON: ${{ steps.python.outputs.python-path }} - VERSION: ${{ inputs.version }} MAIN_BRANCH: ${{ inputs.main-branch }} - DEVELOP_BRANCH: ${{ inputs.develop-branch || inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch }} + PLATFORMS: ${{ inputs.platforms }} + EXTRA_ASSETS: ${{ inputs.extra-assets }} run: | - next=$("$PYTHON" "$SCRIPT" --next) - branch="release/v$next" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git switch -c "$branch" - "$PYTHON" "$SCRIPT" --set "$next" - git commit -am "chore(release): set version to $next" - git push origin "$branch" - body="Set the version on $DEVELOP_BRANCH to $next after the $VERSION release." - if [[ "$DEVELOP_BRANCH" != "$MAIN_BRANCH" ]]; then - body="$body This also merges $MAIN_BRANCH into $DEVELOP_BRANCH. Merge it with a merge commit, not a squash, so the v$VERSION tag is reachable from $DEVELOP_BRANCH." - fi - gh pr create -R "$GITHUB_REPOSITORY" -B "$DEVELOP_BRANCH" -H "$branch" \ - --title "chore(release): set version to $next" --body "$body" + read -ra platforms <<< "$PLATFORMS" + extra=() + while IFS= read -r line; do + if [[ -n "${line//[[:space:]]/}" ]]; then extra+=("$line"); fi + done <<< "$EXTRA_ASSETS" + "$PYTHON" -P -m modflow_devtools.release.workflow \ + --repository "$GITHUB_REPOSITORY" \ + --main-branch "$MAIN_BRANCH" --develop-branch "$DEVELOP_BRANCH" \ + finish --stage "$STAGE" --ref "$REF" --version "$VERSION" \ + --name "$NAME" --long-name "$LONG_NAME" --dist "$DIST" \ + --platforms "${platforms[@]}" --extra-assets "${extra[@]}" diff --git a/actions/release-start/action.yml b/actions/release-start/action.yml index ad3d3a73..b5cb7dd3 100644 --- a/actions/release-start/action.yml +++ b/actions/release-start/action.yml @@ -2,10 +2,11 @@ name: Start a release description: > First step of a release workflow. On workflow_dispatch from the development branch, create release/vX.Y.Z, set the version, update CHANGELOG.md, and - push the branch (stage `prepare`). On push to the main branch, if the - version there is X.Y.Z and untagged, select the commit for release (stage - `publish`). Otherwise do nothing (empty stage). Build the `ref` output - when the stage is set, then run release-finish. + push the branch, or reuse the branch if it exists (stage `prepare`). On + push to the main branch, if the version there is X.Y.Z, untagged, and has a + CHANGELOG.md section, select the commit for release (stage `publish`). + Otherwise do nothing (empty stage). Build the `ref` output when the stage + is set, then run release-finish. See modflow_devtools/release/workflow.py. inputs: path: description: Directory of the program repo checkout @@ -14,7 +15,7 @@ inputs: description: Version to release, X.Y.Z (workflow_dispatch only) default: "" long-name: - description: Program long name, e.g. MODFLOW-USG; used in the shared changelog template + description: Program long name, e.g. MODFLOW-USG; used in the changelog header required: true main-branch: description: Branch releases are merged into and tagged on @@ -25,13 +26,13 @@ inputs: outputs: stage: description: prepare, publish, or empty if there is nothing to do - value: ${{ steps.stage.outputs.stage }} + value: ${{ steps.start.outputs.stage }} ref: description: Branch or commit to build and release - value: ${{ steps.stage.outputs.ref }} + value: ${{ steps.start.outputs.ref }} version: description: Version being released - value: ${{ steps.stage.outputs.version }} + value: ${{ steps.start.outputs.version }} runs: using: composite steps: @@ -42,77 +43,21 @@ runs: python-version: "3.12" update-environment: false - - name: Select stage - id: stage + - name: Start release + id: start shell: bash working-directory: ${{ inputs.path }} env: - SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/version.py PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. VERSION: ${{ inputs.version }} - MAIN_BRANCH: ${{ inputs.main-branch }} - DEVELOP_BRANCH: ${{ inputs.develop-branch || inputs.main-branch }} - run: | - tagged() { git ls-remote --exit-code --tags origin "refs/tags/v$1" >/dev/null; } - stage="" - ref="" - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$GITHUB_REF_NAME" != "$DEVELOP_BRANCH" ]]; then - echo "::error::Start a release from $DEVELOP_BRANCH, not $GITHUB_REF_NAME." - exit 1 - fi - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Version '$VERSION' is not X.Y.Z." - exit 1 - fi - if tagged "$VERSION"; then - echo "::error::Tag v$VERSION already exists." - exit 1 - fi - stage=prepare - ref="release/v$VERSION" - elif [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF_NAME" == "$MAIN_BRANCH" ]]; then - VERSION=$("$PYTHON" "$SCRIPT" --get) - if "$PYTHON" "$SCRIPT" --is-release && ! tagged "$VERSION"; then - stage=publish - ref="$GITHUB_SHA" - fi - fi - echo "stage '$stage', ref '$ref', version '$VERSION'" - { - echo "stage=$stage" - echo "ref=$ref" - echo "version=$VERSION" - } >> "$GITHUB_OUTPUT" - - - name: Install git-cliff - if: steps.stage.outputs.stage == 'prepare' - shell: bash - run: pipx install git-cliff==2.14.2 - - - name: Prepare release branch - if: steps.stage.outputs.stage == 'prepare' - shell: bash - working-directory: ${{ inputs.path }} - env: - SCRIPTS: ${{ github.action_path }}/../../modflow_devtools/release - PYTHON: ${{ steps.python.outputs.python-path }} - VERSION: ${{ steps.stage.outputs.version }} - BRANCH: ${{ steps.stage.outputs.ref }} LONG_NAME: ${{ inputs.long-name }} + MAIN_BRANCH: ${{ inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch }} run: | - # git-cliff needs the history and tags since the last release - if [[ "$(git rev-parse --is-shallow-repository)" == "true" ]]; then - git fetch --quiet --unshallow --tags origin - else - git fetch --quiet --tags origin - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git switch -c "$BRANCH" - "$PYTHON" "$SCRIPTS/version.py" --set "$VERSION" - "$PYTHON" "$SCRIPTS/changelog.py" update --version "$VERSION" \ - --repository "$GITHUB_REPOSITORY" --long-name "$LONG_NAME" - git add -A - git commit -m "chore(release): set version to $VERSION and update changelog" - git push origin "$BRANCH" + "$PYTHON" -P -m modflow_devtools.release.workflow \ + --repository "$GITHUB_REPOSITORY" \ + --main-branch "$MAIN_BRANCH" --develop-branch "$DEVELOP_BRANCH" \ + start --event "$GITHUB_EVENT_NAME" --ref-name "$GITHUB_REF_NAME" \ + --sha "$GITHUB_SHA" --version "$VERSION" --long-name "$LONG_NAME" \ + --output "$GITHUB_OUTPUT" diff --git a/actions/setup/action.yml b/actions/setup/action.yml index 50819afc..e38396d0 100644 --- a/actions/setup/action.yml +++ b/actions/setup/action.yml @@ -1,28 +1,44 @@ name: Set up a MODFLOW program build description: > - Set up the compiler and pixi, and on macOS, Xcode and static linking of the - gcc runtime. Building and testing is left to the caller. + Set up the compiler and pixi, and set LDFLAGS so the compiler runtime + libraries are linked statically. gfortran comes from the repo's pixi + environment (conda-forge), so pixi.toml must depend on gfortran; Intel + compilers come from fortran-lang/setup-fortran. Building and testing is + left to the caller. inputs: path: description: Directory of the program repo checkout, holding pixi.toml default: . compiler: - description: Compiler for fortran-lang/setup-fortran (gcc, intel, intel-classic), or none + description: gcc (gfortran from the pixi environment), intel or intel-classic default: gcc compiler-version: - description: Compiler version - default: "13" + description: Intel compiler version; ignored for gcc, whose version pixi.toml pins + default: "2025.0" xcode-version: description: Xcode version to select on macOS, if any default: "" pixi-version: description: pixi version default: v0.82.1 + pixi-environment: + description: pixi environment to install, which must provide gfortran for gcc builds + default: default runs: using: composite steps: + - name: Check compiler + shell: bash + env: + COMPILER: ${{ inputs.compiler }} + run: | + case "$COMPILER" in + gcc|intel|intel-classic) ;; + *) echo "::error::Unsupported compiler '$COMPILER', expected gcc, intel or intel-classic."; exit 1 ;; + esac + - name: Setup ${{ inputs.compiler }} ${{ inputs.compiler-version }} - if: inputs.compiler != 'none' + if: inputs.compiler != 'gcc' uses: fortran-lang/setup-fortran@v1 with: compiler: ${{ inputs.compiler }} @@ -39,6 +55,7 @@ runs: with: pixi-version: ${{ inputs.pixi-version }} manifest-path: ${{ inputs.path }}/pixi.toml + environments: ${{ inputs.pixi-environment }} - name: Set Xcode version (macOS) if: runner.os == 'macOS' && inputs.xcode-version != '' @@ -46,31 +63,41 @@ runs: with: xcode-version: ${{ inputs.xcode-version }} - - name: Show toolchain (macOS) - if: runner.os == 'macOS' + # Windows Intel builds link the runtime statically through meson's + # b_vscrt option, which LDFLAGS can't set; see the release actions docs + - name: Link the compiler runtime statically shell: bash + env: + COMPILER: ${{ inputs.compiler }} run: | - xcode-select -p - xcrun --show-sdk-version - ld -v 2>&1 | head -2 - if command -v gfortran >/dev/null; then gfortran --version | head -1; fi + case "$COMPILER-$RUNNER_OS" in + gcc-Windows) flags="-static" ;; + gcc-*) flags="-static-libgfortran -static-libgcc -static-libquadmath" ;; + intel*-Linux) flags="-static-intel" ;; + *) flags="" ;; + esac + if [[ -n "$flags" ]]; then + echo "LDFLAGS=${LDFLAGS:+$LDFLAGS }$flags" >> "$GITHUB_ENV" + fi + echo "LDFLAGS: ${LDFLAGS:+$LDFLAGS }$flags" - # hide the gcc runtime dylibs so the linker uses the static libraries - - name: Hide gcc dylibs (macOS gcc) - if: runner.os == 'macOS' && inputs.compiler == 'gcc' + - name: Show toolchain shell: bash + working-directory: ${{ inputs.path }} env: - GCC_VERSION: ${{ inputs.compiler-version }} + COMPILER: ${{ inputs.compiler }} + PIXI_ENV: ${{ inputs.pixi-environment }} run: | - libpath="$(brew --prefix)/opt/gcc@$GCC_VERSION/lib/gcc/$GCC_VERSION" - for lib in libgcc_s.1.1 libgfortran.5 libquadmath.0 libstdc++.6; do - if [[ -f "$libpath/$lib.dylib" ]]; then - mv "$libpath/$lib.dylib" "$libpath/$lib.dylib.bak" + if [[ "$COMPILER" == "gcc" ]]; then + if ! pixi run -e "$PIXI_ENV" gfortran --version | head -1; then + echo "::error::No gfortran in pixi environment $PIXI_ENV, add it to pixi.toml." + exit 1 fi - done - - # classic linker, static libgcc - - name: Set LDFLAGS (macOS gcc) - if: runner.os == 'macOS' && inputs.compiler == 'gcc' - shell: bash - run: echo "LDFLAGS=$LDFLAGS -Wl,-ld_classic -static-libgcc" >> "$GITHUB_ENV" + else + "$FC" --version 2>&1 | head -1 || true + fi + if [[ "$RUNNER_OS" == "macOS" ]]; then + xcode-select -p + xcrun --show-sdk-version + ld -v 2>&1 | head -2 + fi diff --git a/actions/version/action.yml b/actions/version/action.yml index 17407a5d..2f2f490c 100644 --- a/actions/version/action.yml +++ b/actions/version/action.yml @@ -1,7 +1,8 @@ name: Program version description: > - Get, check or set a program's version with modflow_devtools/release/version.py: meson.build - (or the version.txt it reads), pixi.toml, and any files in release.toml. + Get, check or set a program's version with modflow_devtools/release/version.py: + meson.build's project() (or the version.txt it reads), pixi.toml, and any + files in release.toml. inputs: path: description: Directory of the program repo checkout @@ -40,19 +41,19 @@ runs: shell: bash working-directory: ${{ inputs.path }} env: - SCRIPT: ${{ github.action_path }}/../../modflow_devtools/release/version.py PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. SET: ${{ inputs.set }} NEXT: ${{ inputs.next }} run: | if [[ -n "$SET" ]]; then - "$PYTHON" "$SCRIPT" --set "$SET" + "$PYTHON" -P -m modflow_devtools.release.version --set "$SET" fi - version=$("$PYTHON" "$SCRIPT" --get) + version=$("$PYTHON" -P -m modflow_devtools.release.version --get) echo "version=$version" >> "$GITHUB_OUTPUT" - if "$PYTHON" "$SCRIPT" --is-release; then release=true; else release=false; fi + if "$PYTHON" -P -m modflow_devtools.release.version --is-release; then release=true; else release=false; fi echo "release=$release" >> "$GITHUB_OUTPUT" if [[ "$NEXT" == "true" ]]; then - echo "next=$("$PYTHON" "$SCRIPT" --next)" >> "$GITHUB_OUTPUT" + echo "next=$("$PYTHON" -P -m modflow_devtools.release.version --next)" >> "$GITHUB_OUTPUT" fi echo "version $version, release $release" diff --git a/autotest/release/meson.build b/autotest/release/meson.build index cd2719f7..0e8dd59f 100644 --- a/autotest/release/meson.build +++ b/autotest/release/meson.build @@ -22,19 +22,13 @@ if is_double args += ['-real-size', '64', '-double-size', '64'] endif endif -link_args = [] -if fc.get_id() == 'intel-llvm' or fc.get_id() == 'intel' - link_args += ['-static-intel'] -elif fc.get_id() == 'gcc' and host_machine.system() == 'linux' - link_args += ['-static-libgfortran', '-static-libgcc'] -endif +# the setup action sets LDFLAGS to link the compiler runtime statically name = is_double ? 'hellodbl' : 'hello' exe = executable( name, 'src/hello.f90', fortran_args: args, - link_args: link_args, install: true, ) test('kind', exe, args: [is_double ? '8' : '4']) diff --git a/autotest/release/pixi.lock b/autotest/release/pixi.lock index ac69244c..69f817d7 100644 --- a/autotest/release/pixi.lock +++ b/autotest/release/pixi.lock @@ -22,15 +22,23 @@ environments: packages: linux-64: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-15.3.0-h8846f6e_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-15.3.0-hc6a0c74_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.3.0-h6ad849d_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran-15.3.0-hc6a0c74_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-15.3.0-h27ced10_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.9.0-hd2095e1_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.3.0-h54950a5_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h0737f62_101.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.2.0-h934c35e_7.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_1.conda @@ -42,41 +50,99 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.3.0-h2b852eb_107.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.3.0-hb2c5482_107.conda - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt23_osx-arm64-23.1.5-hb8825d9_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-23.1.5-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_osx-arm64-15.3.0-h647fcfe_107.conda - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm23_1_h54b4f4b_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm23_1_hf5e010b_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm23_1_h54b4f4b_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-23-23.1.5-default_hf8b46ca_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-23.1.5-default_cfg_hb04f594_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-23.1.5-default_h8bbc02d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-23.1.5-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt23-23.1.5-hdb3d66b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-gcc-specs-15.3.0-h64e22ab_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gcc-15.3.0-h500d687_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gcc_impl_osx-arm64-15.3.0-hdd9f9fa_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gfortran-15.3.0-h500d687_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gfortran_impl_osx-arm64-15.3.0-hc97e033_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm23_1_hd841b0c_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm23_1_hc8058f9_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp23.1-23.1.5-default_h8bbc02d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcompiler-rt-23.1.5-hdb3d66b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.5-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.9.0-h6111c0a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.2.0-h3cf6597_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.2.0-hdb7a957_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm23-23.1.5-h759d1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-h34a968b_101.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-hea22233_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-hdcc4aff_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.5-hdb3d66b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-23-23.1.5-h79441dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-23.1.5-hdb3d66b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ninja-1.13.2-h3feff0a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.15.2-h2e4dbab_100_cp315.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.10.23-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_win-64-15.3.0-h5e97db5_107.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_win-64-15.3.0-hd8cc3d9_107.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/m2w64-sysroot_win-64-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-crt-git-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-headers-git-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-windows-default-manifest-6.4-he206cdd_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-winpthreads-git-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/binutils_impl_win-64-2.46.1-default_ha84baeb_103.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/conda-gcc-specs-15.3.0-h9183aac_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/gcc-15.3.0-hb5e953d_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/gcc_impl_win-64-15.3.0-ha27ca61_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/gfortran-15.3.0-hb5e953d_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/gfortran_impl_win-64-15.3.0-h1f40a57_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ld_impl_win-64-2.46.1-default_hfd38196_103.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.9.0-hac47afa_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-16.2.0-h110b43a_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgfortran5-16.2.0-h7c9de5e_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-16.2.0-h8ee18e1_7.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.54.1-hf5d6505_101.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libstdcxx-16.2.0-hae5796f_7.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r688.g6b2176247.1-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/m2-conda-epoch-20261029-0_x86_64.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ninja-1.13.2-h477610d_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.15.2-h53f6dd8_100_cp315.conda @@ -103,6 +169,18 @@ packages: - _openmp_mutex >=4.5 size: 28948 timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_103.conda + sha256: 98f9ddc6dc5d56a824b7eaea5bab6ad4bb47642288fc70d6b1b4024ac39d5595 + md5: e86f9dc615d7533c5be8b15b3a1dee8d + depends: + - ld_impl_linux-64 2.46.1 default_hbd61a6d_103 + - sysroot_linux-64 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 3706604 + timestamp: 1791565481839 - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 md5: e675fabcf81499adc7edf58124fb1e01 @@ -116,6 +194,71 @@ packages: - bzip2 >=1.0.8,<2.0a0 size: 257808 timestamp: 1785906269155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-15.3.0-h8846f6e_7.conda + sha256: 395a5928fc78e8545afdb4e9bf074cf90e37ff8287a1f34cd98c9350da6c3095 + md5: da715e003b8f6e6737923cda77f67dbf + depends: + - gcc_impl_linux-64 >=15.3.0,<15.3.1.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 32760 + timestamp: 1790302817774 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-15.3.0-hc6a0c74_7.conda + sha256: f362d14caab740de46f60d5ca8a4340066704ff573bc0a618030ceca3a2b7f87 + md5: f86312df66bf0d79961e8e185e4f9957 + depends: + - conda-gcc-specs + - gcc_impl_linux-64 15.3.0 h6ad849d_7 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 29624 + timestamp: 1790302921468 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.3.0-h6ad849d_7.conda + sha256: 3c6deaf411a00aef601fb37f3a5e00aa4b85bace1fd2ebad347033aadc543c55 + md5: 61f2e6900de1f2aff25717b332ed6dac + depends: + - binutils_impl_linux-64 >=2.46.1 + - libgcc >=15.3.0 + - libgcc-devel_linux-64 15.3.0 h2b852eb_107 + - libgomp >=15.3.0 + - libsanitizer 15.3.0 h54950a5_7 + - libstdcxx >=15.3.0 + - libstdcxx-devel_linux-64 15.3.0 hb2c5482_107 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 83849972 + timestamp: 1790302722312 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran-15.3.0-hc6a0c74_7.conda + sha256: 028881b2debf0f2121782864d1739be89b9f36525e131d310811c355184fa661 + md5: 7d812c5e996b22b7037ca4f6c35f294e + depends: + - conda-gcc-specs + - gcc 15.3.0 hc6a0c74_7 + - gcc_impl_linux-64 15.3.0 h6ad849d_7 + - gfortran_impl_linux-64 15.3.0 h27ced10_7 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 28905 + timestamp: 1790302940220 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gfortran_impl_linux-64-15.3.0-h27ced10_7.conda + sha256: 944f5bd6737a4bb6b079f228ef6eaf7f5c9ce6ac76ccdfef71e95fd940b14870 + md5: 5c69dab673a8a43748039694be219190 + depends: + - gcc_impl_linux-64 15.3.0.* + - libgcc >=15.3.0 + - libgfortran5 >=15.3.0 + - libstdcxx >=15.3.0 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 20647168 + timestamp: 1790302855875 - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda sha256: 901649b1edbe841fd4ee7e2d491a05184ed540eb01cdae506872f344babc1d28 md5: 2362c70e7e79827c32db35547d9c179b @@ -184,6 +327,19 @@ packages: run_exports: {} size: 1058444 timestamp: 1790303109576 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda + sha256: 04dbab12c925b92977bb835dd2f5eda413607afa79f95a554e5a04b07a380322 + md5: 7719ed7be38ad8c492c4bd5cc2bce0c4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=16.2.0 + constrains: + - libgfortran 16.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 2528180 + timestamp: 1790303122004 - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda sha256: 92a0bb350c7be6a4eedcb4e111c60aefd176018e6b1c009a6e025dbe9bbcd758 md5: d904c7ddd95e026254b4b80808ee9e12 @@ -221,6 +377,20 @@ packages: run_exports: {} size: 92759 timestamp: 1786650399772 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.3.0-h54950a5_7.conda + sha256: 9e5518e38502568f4591be965d48c1a3f79e2da4d74567f1a62e00244cf40222 + md5: 04361d154f650289702f20c0ff86d7be + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.3.0 + - libstdcxx >=15.3.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + weak: + - libsanitizer 15.3.0 + size: 8073659 + timestamp: 1790302682129 - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h0737f62_101.conda sha256: c294029f893d05286f1061bbab85b820f1eea73dd859af4bcc619197bcc34810 md5: 36e097bf5f1e0699e88c8ac801f2dc65 @@ -393,6 +563,103 @@ packages: run_exports: {} size: 130057 timestamp: 1792772446739 +- conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt23_osx-arm64-23.1.5-hb8825d9_0.conda + sha256: 3c3e19ea5a1832bc4febca044df2f5b5b09d7121c4d1b9e3fb8f7287bc181cf2 + md5: 9710053129d00681f11142c98953fce6 + constrains: + - compiler-rt >=9.0.1 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 10317097 + timestamp: 1794314343828 +- conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-23.1.5-hce30654_0.conda + sha256: 7805614e4bdb6087aaa21889cffb7db904768b8d87bc208556f67ae424ef893c + md5: 4eb5f9167533417ab4cd0fbcc7b18dc2 + depends: + - compiler-rt23_osx-arm64 23.1.5 hb8825d9_0 + constrains: + - clang 23.1.5 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 16663 + timestamp: 1794314365047 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.3.0-h2b852eb_107.conda + sha256: 5200a1402eb3c3e9b0e4ba38b3cf163be136d020a15477fb86ce6e754d3ae515 + md5: 057ab488a4da13485caa3b672df9824a + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 3085843 + timestamp: 1790302578256 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_osx-arm64-15.3.0-h647fcfe_107.conda + sha256: 89859324c8f2f89eae9a478071862d0571c1769a35148c36b8195d8273f5a1b3 + md5: 13a4106eb56b34138f6b2f78950172a2 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 2064871 + timestamp: 1790301920884 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_win-64-15.3.0-h5e97db5_107.conda + sha256: 356a39007d458e1f7cd381a2341af9abd80280e695970978e0c012af4c847a45 + md5: 2d3c97958028673cea9e5a6085d43a7b + depends: + - m2-conda-epoch + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 2417715 + timestamp: 1790304309485 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.3.0-hb2c5482_107.conda + sha256: 4b2d94a9abf2feedea354e4256756c5dc255b84080e7faed9a324a101cf20e74 + md5: edce245dade1c29f691983796b4b8414 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 20673146 + timestamp: 1790302599776 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_win-64-15.3.0-hd8cc3d9_107.conda + sha256: e4ced8b01a85c245f8124aba24cf14c6c77c1e540f517fd4c299ad1e56fb8262 + md5: f57afc57a37013cea43e75145af291ac + depends: + - m2-conda-epoch + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 11683597 + timestamp: 1790304347297 +- conda: https://conda.anaconda.org/conda-forge/noarch/m2w64-sysroot_win-64-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda + sha256: fb0ffe6b3c25189038c29abbd1fac2522d87fe2775a09e5f5088e5542dc3309b + md5: 9676d2a30fa3ffa4e5350041d0993758 + depends: + - m2-conda-epoch + - mingw-w64-ucrt-x86_64-crt-git 12.0.0.r4.gg4f2fc60ca hd8ed1ab_10 + - mingw-w64-ucrt-x86_64-headers-git 12.0.0.r4.gg4f2fc60ca hd8ed1ab_10 + - mingw-w64-ucrt-x86_64-windows-default-manifest + - mingw-w64-ucrt-x86_64-winpthreads-git 12.0.0.r4.gg4f2fc60ca hd8ed1ab_10 + - ucrt + run_exports: + strong: + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + size: 8421 + timestamp: 1759768559974 - conda: https://conda.anaconda.org/conda-forge/noarch/meson-1.12.1-pyh5ded981_0.conda sha256: b8f20bb684aa5b4e7e277eb2ca82d2b1bc109a655e10c651008ef948d90c8884 md5: 669b846a7346f2f364947410a39c558c @@ -405,6 +672,53 @@ packages: run_exports: {} size: 798852 timestamp: 1790167557353 +- conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-crt-git-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda + sha256: de3e42149b498c16bfb485b7729f4ca0fe392be576a2a10ff702d661799b1df3 + md5: 44ffa6d68699ec9321f6d48d75bdc726 + depends: + - m2-conda-epoch + - mingw-w64-ucrt-x86_64-headers-git 12.0.0.r4.gg4f2fc60ca hd8ed1ab_10 + constrains: + - mingw-w64-ucrt-x86_64-winpthreads-git 12.0.0.r4.gg4f2fc60ca.* + license: ZPL-2.1 + run_exports: {} + size: 5663635 + timestamp: 1759768458961 +- conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-headers-git-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda + sha256: 1add86481f35163215e7076e6f06f22aa9f1f9345a5fff5cb07bc846c13fbec7 + md5: cab7b807024204893ef5bb1860d91408 + depends: + - m2-conda-epoch + constrains: + - mingw-w64-ucrt-x86_64-crt-git 12.0.0.r4.gg4f2fc60ca.* + - mingw-w64-ucrt-x86_64-winpthreads-git 12.0.0.r4.gg4f2fc60ca.* + license: ZPL-2.1 AND LGPL-2.1-or-later + run_exports: {} + size: 7089846 + timestamp: 1759768412123 +- conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-windows-default-manifest-6.4-he206cdd_7.conda + sha256: 5b0df4e0ba8487ffd59f60c34c5dbb9e001ecd2c5d2c66ba88eada40bfa3ecb8 + md5: 1d6b5c96d7e3cce773519d7d1a4482f0 + depends: + - __win + constrains: + - m2w64-sysroot_win-64 >=12.0.0.r0 + license: FSFAP + run_exports: {} + size: 7412 + timestamp: 1717486007140 +- conda: https://conda.anaconda.org/conda-forge/noarch/mingw-w64-ucrt-x86_64-winpthreads-git-12.0.0.r4.gg4f2fc60ca-hd8ed1ab_10.conda + sha256: 828abb111286940473c4c665fc8ab300d28920f5af83b32295e8bf2256a8f342 + md5: ba0eeff6a5c62b83c771bb392e22dbb4 + depends: + - m2-conda-epoch + - mingw-w64-ucrt-x86_64-headers-git 12.0.0.r4.gg4f2fc60ca hd8ed1ab_10 + constrains: + - mingw-w64-ucrt-x86_64-crt-git 12.0.0.r4.gg4f2fc60ca.* + license: MIT AND BSD-3-Clause-Clear + run_exports: {} + size: 123916 + timestamp: 1759768539535 - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.15-9_cp315.conda build_number: 9 sha256: 976c4a6db967a62385aa633daaab19a929f986b979a7ad836b8b70e15ca7aefd @@ -416,6 +730,20 @@ packages: run_exports: {} size: 6800 timestamp: 1788302827589 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: + strong: + - __glibc >=2.28,<3.0.a0 + size: 24008591 + timestamp: 1765578833462 - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026e-h5d301ff_0.conda sha256: 2b7ca858879f183ce2dcc65b300f48a0133d5430550f35b23d80d405a0788dbf md5: abccc3810d2b97e570dee487f2b46e38 @@ -423,6 +751,19 @@ packages: run_exports: {} size: 119208 timestamp: 1792758382821 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda + build_number: 8 + sha256: c7c98ce74f6a7ff25aaa4706d06fa41d63a333add4d697b73aa4d8d2d7ad7651 + md5: a2d706b4a7d603524133037c34f7fb76 + depends: + - llvm-openmp >=9.0.1 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - _openmp_mutex >=4.5 + size: 8016 + timestamp: 1788046437162 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 md5: b50612e7d190b8061ab4e7dc119cf4d5 @@ -435,6 +776,255 @@ packages: - bzip2 >=1.0.8,<2.0a0 size: 124965 timestamp: 1785906749812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm23_1_h54b4f4b_7.conda + sha256: 8381a7ec65cab8a622afab728785121f36b564e20e0dc99e0059425ee0f755c3 + md5: 909e4cd297d68c5706484bb0f6224a08 + depends: + - cctools_impl_osx-arm64 1030.6.3 llvm23_1_hf5e010b_7 + - ld64 956.6 llvm23_1_hd841b0c_7 + - libllvm23 >=23.1.4,<23.2.0a0 + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 24477 + timestamp: 1793267600896 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm23_1_hf5e010b_7.conda + sha256: c1382d863b5c16de1d90779e861144fa6c6b91505e03a93ef27c46c039395b12 + md5: 01f4cafe27b17957421dca505640ef19 + depends: + - __osx >=11.0 + - ld64_osx-arm64 >=956.6,<956.7.0a0 + - libcxx + - libllvm23 >=23.1.4,<23.2.0a0 + - libzlib >=1.3.2,<2.0a0 + - llvm-tools 23.1.* + - sigtool-codesign + constrains: + - cctools 1030.6.3.* + - clang 23.1.* + - ld64 956.6.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 759788 + timestamp: 1793267590618 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm23_1_h54b4f4b_7.conda + sha256: c8198297cbcbd07dd5d67879d4b6d9d75d98c9770816d8b7c3857da6045caa53 + md5: 4aafb3391e2c18c591313f479c459099 + depends: + - cctools_impl_osx-arm64 1030.6.3 llvm23_1_hf5e010b_7 + - ld64_osx-arm64 956.6 llvm23_1_hc8058f9_7 + constrains: + - cctools 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 23662 + timestamp: 1793267603602 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-23-23.1.5-default_hf8b46ca_0.conda + sha256: 93742f3383edf1f7dc379871df9d5c8337d4c6d7cd7b2e8418b92ddc234b6bf1 + md5: 6dd8c9bd0d3186feca2681c2c5c43fd9 + depends: + - compiler-rt23 ==23.1.5 + - libclang-cpp23.1 ==23.1.5 default_h8bbc02d_0 + - libcxx >=23.1.5 + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.4 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libclang-cpp23.1 >=23.1.5,<23.2.0a0 + - libllvm23 >=23.1.5,<23.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 955200 + timestamp: 1794346178443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-23.1.5-default_cfg_hb04f594_0.conda + sha256: eddf0e4c97f3613522dd3cc65e984f5f11cd3dfac773531ba7b958978d1dd0ea + md5: 50700a4adb5ff1b417d134735df21c50 + depends: + - clang-23 ==23.1.5 default_* + - llvm-openmp >=23.1.5 + - clang_impl_osx-arm64 ==23.1.5 default_h8bbc02d_0 + - cctools + - ld64 + - ld64_osx-arm64 * llvm23_1_* + - llvm-tools ==23.1.5 + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 32309 + timestamp: 1794346178443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-23.1.5-default_h8bbc02d_0.conda + sha256: bf3ac6a17bbaa2ae9011c9bf9e8e4ba10000876ece755cdcb5682e9c6311a72e + md5: fbd9f3c5eb2d256ce1cfb8abeb3dae19 + depends: + - clang-23 ==23.1.5 default_* + - compiler-rt_osx-arm64 ==23.1.5 + - compiler-rt ==23.1.5 + - cctools_impl_osx-arm64 + - ld64_osx-arm64 * llvm23_1_* + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.4 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 31933 + timestamp: 1794346178443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-23.1.5-hce30654_0.conda + sha256: 72872654809e94ca8c40c1e079dc09672a9d345612bab8d0a3a3bbf107f15861 + md5: 02afbf09cf8c5edd2abd483e65be4633 + depends: + - compiler-rt23 23.1.5 hdb3d66b_0 + - libcompiler-rt 23.1.5 hdb3d66b_0 + constrains: + - clang 23.1.5 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 16525 + timestamp: 1794314365500 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt23-23.1.5-hdb3d66b_0.conda + sha256: c784967564de556c668be397ac3a9e6c743dbc8a1efc31dc117399185dee0621 + md5: 5ae378043d8b242b233bd34deb3d8259 + depends: + - __osx >=11.0 + - compiler-rt23_osx-arm64 23.1.5.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 100671 + timestamp: 1794314364416 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/conda-gcc-specs-15.3.0-h64e22ab_7.conda + sha256: f1a3ea87b09cc2965d67d874de5c3580246080659a371317506476b0d07da865 + md5: b63660060bb4f4f01541ed4a60d4e74a + depends: + - gcc_impl_osx-arm64 >=15.3.0,<15.3.1.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 103706 + timestamp: 1790302016076 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gcc-15.3.0-h500d687_7.conda + sha256: f7fff997d8a198ef274d8bae96f5d6d6b7b493edea24753e2d057dbaf611d8ec + md5: 0c68aaee12368c7ea0fe3d7c8ec22121 + depends: + - conda-gcc-specs + - gcc_impl_osx-arm64 15.3.0 hdd9f9fa_7 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 100726 + timestamp: 1790302097564 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gcc_impl_osx-arm64-15.3.0-hdd9f9fa_7.conda + sha256: 49f180e56b14bc5fa77b754a8a5055a90f98c00ed11b718930761a789654d5ec + md5: 0a59d7ef1ab966e82dee4f3608ead2b1 + depends: + - cctools_osx-arm64 + - clang + - ld64_osx-arm64 + - libgcc >=15.3.0 + - libgcc-devel_osx-arm64 15.3.0 h647fcfe_107 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 49731022 + timestamp: 1790301960206 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gfortran-15.3.0-h500d687_7.conda + sha256: 08bcdc2786da0471158487e351bb84c15a1105ba092456da2f9a8984eb35b2d8 + md5: 494f0201401df70c6b570ddb8e2ab923 + depends: + - conda-gcc-specs + - gcc 15.3.0 h500d687_7 + - gcc_impl_osx-arm64 15.3.0 hdd9f9fa_7 + - gfortran_impl_osx-arm64 15.3.0 hc97e033_7 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 100322 + timestamp: 1790302115518 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gfortran_impl_osx-arm64-15.3.0-hc97e033_7.conda + sha256: 69ae6942ff649a748b7c58f5faa7d30e344afd34bec36fe954d83232afaee08b + md5: 1a31dbbcff4880651b42a5e750b46939 + depends: + - gcc_impl_osx-arm64 15.3.0.* + - libgcc >=15.3.0 + - libgfortran5 >=15.3.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 13101317 + timestamp: 1790302052881 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm23_1_hd841b0c_7.conda + sha256: e2962e93cc136cecf410e0b2831d3121620074e3cca992a6707f8bff09b8bdc5 + md5: 6ab589f8dad18a6ca5e1e6b2bb157404 + depends: + - ld64_osx-arm64 956.6 llvm23_1_hc8058f9_7 + - libllvm23 >=23.1.4,<23.2.0a0 + constrains: + - cctools 1030.6.3.* + - cctools_osx-arm64 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 21760 + timestamp: 1793267596039 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm23_1_hc8058f9_7.conda + sha256: 645c246e045af89a6c710964f9f7a1e4a83e0e2cad38e21ffd005eca3553f044 + md5: e3b8ad49fc571fc1006e74cbf48114b3 + depends: + - __osx >=11.0 + - libcxx + - libllvm23 >=23.1.4,<23.2.0a0 + - sigtool-codesign + - tapi >=1600.0.11.8,<1601.0a0 + constrains: + - cctools 1030.6.3.* + - cctools_impl_osx-arm64 1030.6.3.* + - clang 23.1.* + - ld64 956.6.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 984450 + timestamp: 1793267572141 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp23.1-23.1.5-default_h8bbc02d_0.conda + sha256: 64fa0320233ace12b0cca4b4353fe4ca18df6231e1918b44a32b70873c308e6b + md5: dcba6c482e85ab895ab7c94a6a6ff3bb + depends: + - libcxx >=23.1.5 + - __osx >=11.0 + - libxml2 + - libxml2-16 >=2.15.4 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libllvm23 >=23.1.5,<23.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libclang-cpp23.1 >=23.1.5,<23.2.0a0 + size: 16605399 + timestamp: 1794346178443 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcompiler-rt-23.1.5-hdb3d66b_0.conda + sha256: 15986a0792684012dcb1dadfbc10e3c50dc208f6e2ebff4f4b122899830b543b + md5: 3dc29ee6be9393be611fb1c28e8de153 + depends: + - __osx >=11.0 + constrains: + - compiler-rt >=9.0.1 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libcompiler-rt >=23.1.5 + size: 1385142 + timestamp: 1794314359473 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.5-h55c6f16_0.conda sha256: b4b1fd6107980fed8b4e7ae8b08257b50c0f88b8771a530dc4a561f5d796457b md5: df8bd5c8eaa14aaa86806be695bda0b1 @@ -483,6 +1073,59 @@ packages: - libffi >=3.7.0,<3.8.0a0 size: 43637 timestamp: 1787753403688 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.2.0-h3cf6597_7.conda + sha256: deacf62bd6cbbe05eb085dd69a2ca120053597c37066d53c8f7cf6815f26e9eb + md5: f100c8baeb0c816052b0d43948555bf6 + depends: + - _openmp_mutex + constrains: + - libgcc-ng ==16.2.0=*_7 + - libgomp 16.2.0 7 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 365973 + timestamp: 1790301995634 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.2.0-hdb7a957_7.conda + sha256: 19297e5cf39de976cdd1a46499a4e15fdf54d249d3231f3b2a9d43af3be0bc66 + md5: c9d80dd8775b8fe76e825d63d8ccaf1b + depends: + - libgcc >=16.2.0 + constrains: + - libgfortran 16.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 554793 + timestamp: 1790302001166 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-he4c29f2_3.conda + sha256: 689a14968267f2f97c07112fca5636e7d756036b9aee969911267c20bd3eea1f + md5: 17f4744c0873e9f77ac9b5cd0a27c187 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 750816 + timestamp: 1787033961086 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm23-23.1.5-h759d1ac_0.conda + sha256: 9288fdfba716a08aa6dca482a180ebf1bcf1cd1573d479b92e39d6c7cf97b412 + md5: 7ef5c8087c26cba74fb5cde17e8ee056 + depends: + - __osx >=11.0 + - libcxx >=21 + - libxml2 + - libxml2-16 >=2.15.4 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm23 >=23.1.5,<23.2.0a0 + size: 31570443 + timestamp: 1793743305449 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311 md5: 8ab10323068b107661a4b9a4af84f3b5 @@ -506,6 +1149,17 @@ packages: run_exports: {} size: 73289 timestamp: 1786651074391 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_1.conda + sha256: fcfa03afe31f40dfabf011629d99836adbebbc3ed1b38c7e9eee9ffc7581975b + md5: c70eb797aa92a294b09ef8f41f6bd578 + depends: + - __osx >=11.0 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 36651 + timestamp: 1786115069018 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-h34a968b_101.conda sha256: c49adf700ad17332b938ac627845a3fdc70a0810660d3ec3814de25b156fbf60 md5: 6b3b2ea51e2685192a790730c59dbb01 @@ -520,6 +1174,41 @@ packages: - libsqlite >=3.54.1,<4.0a0 size: 949450 timestamp: 1793051666641 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.4-hea22233_1.conda + sha256: 458e6043db6d661b8914a3252b85b3d693e315711848efb92885fe7694f28de9 + md5: cf7e9aa187749e5f475e70f43c80829a + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + - libxml2 2.15.4 + license: MIT + license_family: MIT + run_exports: {} + size: 468462 + timestamp: 1791465618992 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.4-hdcc4aff_1.conda + sha256: 87b6bb897ff739c5aca1adbd026e730443cff2de01ee7185de9fa7496bf8a2ce + md5: 5d827ab96b9a0b11f34aeab142b556d7 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.4 hea22233_1 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.4 + size: 41124 + timestamp: 1791465623698 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 md5: f39288f0ea63ae962e1a2e4f355a0d75 @@ -534,6 +1223,52 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 47822 timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.5-hdb3d66b_0.conda + sha256: 7a68977e62bd0c967509a7a01a25c215bb8027071dafe03aa1976fd8ade94013 + md5: e726aab6d8cb01515477f18ba47aab78 + depends: + - __osx >=11.0 + constrains: + - intel-openmp <0.0a0 + - openmp 23.1.5|23.1.5.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + strong: + - llvm-openmp >=23.1.5 + size: 287928 + timestamp: 1793827550286 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-23-23.1.5-h79441dc_0.conda + sha256: e79c2c30133f6187b87534d191f69f6d9a51478c40a63c7605794c790e4599ac + md5: 3568bdf1a76900db43b81e8e6125d346 + depends: + - __osx >=11.0 + - libcxx >=21 + - libllvm23 23.1.5 h759d1ac_0 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 18500379 + timestamp: 1793743397019 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-23.1.5-hdb3d66b_0.conda + sha256: f0d66a877aa39288bc77cfedf86d1ab976a5f2534b5bc9f1899610528d25f62c + md5: b2152633793171363b9d10deff647496 + depends: + - __osx >=11.0 + - libllvm23 23.1.5 h759d1ac_0 + - llvm-tools-23 23.1.5 h79441dc_0 + constrains: + - clang 23.1.5 + - clang-tools 23.1.5 + - llvm 23.1.5 + - llvmdev 23.1.5 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 52113 + timestamp: 1793743440755 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda sha256: 7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68 md5: 3dfa0d0316dc246cd44937a557de4501 @@ -598,6 +1333,29 @@ packages: size: 13419531 timestamp: 1793014795433 python_site_packages_path: lib/python3.15/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_1.conda + sha256: 7efd6d7d18bf9cc5788bfe1c1e1620d9dbbe00a81c646814929a82ff31944cf3 + md5: a322c0a0d3b5be99ee11f9ccec99b60e + depends: + - __osx >=11.0 + - libsigtool 0.1.3 h98dc951_1 + - openssl >=3.5.7,<4.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 114653 + timestamp: 1786115093248 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_4.conda + sha256: 6effea9587f049e702c838e8e57d98ac92b89ca1f3f1edd51c78dd252d24e83b + md5: fdb31cc6c306f60e194f29f17397a63c + depends: + - libcxx >=19.0.0.a0 + - __osx >=11.0 + - ncurses >=6.6,<7.0a0 + license: NCSA + run_exports: {} + size: 202107 + timestamp: 1789668272580 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda sha256: 857d89087ae7f2c328cf256728affcb7343031a148b4af847334d248a9cf564c md5: 90a01bf394d1c594e0eb9258f967d828 @@ -623,6 +1381,35 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 433687 timestamp: 1786599629846 +- conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 8a1cee28bd0ee7451ada1cd50b64720e57e17ff994fc62dd8329bef570d382e4 + md5: 1626967b574d1784b578b52eaeb071e7 + depends: + - libgomp >=7.5.0 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - openmp_impl <0.0a0 + - msys2-conda-epoch <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 52252 + timestamp: 1770943776666 +- conda: https://conda.anaconda.org/conda-forge/win-64/binutils_impl_win-64-2.46.1-default_ha84baeb_103.conda + sha256: 4f165824588d2600d202efc14727a5360f75508134a58b12943104f4731c696f + md5: 3200f814fad9ec6ba854f88b0dbdeed7 + depends: + - ld_impl_win-64 2.46.1 default_hfd38196_103 + - m2w64-sysroot_win-64 >=12.0.0.r0 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 6484617 + timestamp: 1791565500053 - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_10.conda sha256: 04767466ee9227c9c57ab2c6503e0149177d34111c7418d2f420297acb1eb229 md5: c3301c058362f340100d91cd8be0393f @@ -637,6 +1424,82 @@ packages: - bzip2 >=1.0.8,<2.0a0 size: 55919 timestamp: 1785906343696 +- conda: https://conda.anaconda.org/conda-forge/win-64/conda-gcc-specs-15.3.0-h9183aac_7.conda + sha256: 032c5b5348412c395a7459f64703dd318f6021479421824c648b3d88cf7c6ce7 + md5: 9aae2390f5b08814bfd3f0b03aa141b4 + depends: + - gcc_impl_win-64 >=15.3.0,<15.3.1.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 53897 + timestamp: 1790304567834 +- conda: https://conda.anaconda.org/conda-forge/win-64/gcc-15.3.0-hb5e953d_7.conda + sha256: 560b32c0e89bfdffd8343dcbcacf06471da67551a35304261dcebc8cfe0188be + md5: 60867f445509ae487157d4f156f74288 + depends: + - conda-gcc-specs + - gcc_impl_win-64 15.3.0 ha27ca61_7 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 1196136 + timestamp: 1790304680896 +- conda: https://conda.anaconda.org/conda-forge/win-64/gcc_impl_win-64-15.3.0-ha27ca61_7.conda + sha256: 1a3c67bf2445caed883770c473efd54b0a0939bc5d2acadb0b179dca360012fe + md5: 36873c992238fac3402fe39f4a9de3f5 + depends: + - binutils_impl_win-64 >=2.46.1 + - libgcc >=15.3.0 + - libgcc-devel_win-64 15.3.0 h5e97db5_107 + - libgomp >=15.3.0 + - libstdcxx >=15.3.0 + - libstdcxx-devel_win-64 15.3.0 hd8cc3d9_107 + - m2w64-sysroot_win-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 62078144 + timestamp: 1790304441596 +- conda: https://conda.anaconda.org/conda-forge/win-64/gfortran-15.3.0-hb5e953d_7.conda + sha256: 65424eda7a7ef85a8f06b579ada6c50af7efde86afdb250a68b3deac680577b3 + md5: 2565489c3528dcc9510c5f0522b2b992 + depends: + - conda-gcc-specs + - gcc 15.3.0 hb5e953d_7 + - gcc_impl_win-64 15.3.0 ha27ca61_7 + - gfortran_impl_win-64 15.3.0 h1f40a57_7 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 821786 + timestamp: 1790304701355 +- conda: https://conda.anaconda.org/conda-forge/win-64/gfortran_impl_win-64-15.3.0-h1f40a57_7.conda + sha256: 198b2cdd3aece29ba56586dcf71013f771ed786087ec39819b9976c2df319b2c + md5: 72063086dae723ec1e09333cb64222df + depends: + - gcc_impl_win-64 15.3.0.* + - libgcc >=15.3.0 + - libgfortran5 >=15.3.0 + - libstdcxx >=15.3.0 + - m2w64-sysroot_win-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 16735848 + timestamp: 1790304609989 +- conda: https://conda.anaconda.org/conda-forge/win-64/ld_impl_win-64-2.46.1-default_hfd38196_103.conda + sha256: 4d84eaf3073ccc3935777e73d5508770c370d62279c1af261118cc91c5d55b73 + md5: b8bcce8565b7abaf55f8f414c87dc1e7 + depends: + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_win-64 2.46.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 899363 + timestamp: 1791565484854 - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.9.0-hac47afa_0.conda sha256: d4a8e5aac8999a90cd71aa6f9f5c40d8594ed2b89cd7f898f997767ecc6cd52d md5: 74ac404611024dc488ae71cfb7a5bf8f @@ -665,6 +1528,49 @@ packages: - libffi >=3.7.0,<3.8.0a0 size: 49992 timestamp: 1787753517346 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-16.2.0-h110b43a_7.conda + sha256: 59feb6d16cba13a70f22dae70a44670eb2cc45e652e9a34a014814c2ca871132 + md5: bd1e0d06981ae43e35c1ebb50e268c59 + depends: + - _openmp_mutex >=4.5 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - libgcc-ng ==16.2.0=*_7 + - libgomp 16.2.0 h8ee18e1_7 + - msys2-conda-epoch <0.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 826913 + timestamp: 1790304405241 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgfortran5-16.2.0-h7c9de5e_7.conda + sha256: e9b86fe14cb9938abd08f0b1685b04457339f71d9f00783fe9258a1eb0229963 + md5: 237c6a7be7b5bae7f4b5b70635ef4f79 + depends: + - libgcc >=16.2.0 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - libgfortran 16.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 2814898 + timestamp: 1790304412365 +- conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-16.2.0-h8ee18e1_7.conda + sha256: 4b8a747f047d069ccd2a6cf2f4fdbc6b0c0e4dcd840998d1fe607b8c234e707b + md5: 1725e0c19dd2f01de09805536c4f3b51 + depends: + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - msys2-conda-epoch <0.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + - libgomp >=16.2.0 + size: 688609 + timestamp: 1790304345963 - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda sha256: d36c4a1e1f80fd08e18a407e03622ff2f34dfdd022da6488ad19603dea19e6d5 md5: 880a0c8549479b198af21ba5dc49b109 @@ -705,6 +1611,30 @@ packages: - libsqlite >=3.54.1,<4.0a0 size: 1333137 timestamp: 1793051563198 +- conda: https://conda.anaconda.org/conda-forge/win-64/libstdcxx-16.2.0-hae5796f_7.conda + sha256: 5984f81dcb289446fb3cdb0117c0680bded57eaa98da193f705400ec94fc71ae + md5: cdc259116f09ebebe2442f855aa03cfd + depends: + - libgcc 16.2.0 h110b43a_7 + - libwinpthread >=12.0.0.r4.gg4f2fc60ca + constrains: + - libstdcxx-ng ==16.2.0=*_7 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 7217469 + timestamp: 1790304423098 +- conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r688.g6b2176247.1-h57928b3_0.conda + sha256: bab5558663cb0e964988b859abd69d0f2380b6ea9f140495ab543e51af13635d + md5: a1b8ebbb78e979c42b2bdd97ad7756e0 + constrains: + - msys2-conda-epoch <0.0a0 + - pthreads-win32 <0.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 34475 + timestamp: 1793054490651 - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda sha256: 0629c2cc0404d3bb29d6baa7b4ba62da80797015e86de050db81ea5a07050527 md5: 5d2ff29d465097458cc3ff6569151991 @@ -721,6 +1651,21 @@ packages: - libzlib >=1.3.2,<2.0a0 size: 58529 timestamp: 1785276664143 +- conda: https://conda.anaconda.org/conda-forge/win-64/m2-conda-epoch-20261029-0_x86_64.conda + build_number: 0 + sha256: 10e83837e3dd4b9da952d892b976a5dd8d67d97217942f441a43d053e815c1d7 + md5: 51e02aff9e4270d4b8267aea3514750e + constrains: + - msys2-conda-epoch <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - m2-conda-epoch ==20261029 *_x86_64 + noarch: + - m2-conda-epoch ==20261029 *_x86_64 + size: 3484 + timestamp: 1793325084207 - conda: https://conda.anaconda.org/conda-forge/win-64/ninja-1.13.2-h477610d_1.conda sha256: 002bce37e87e39c6a1e5577b1018d294cd4517ba9c4be2ece92e5706acac41f5 md5: 3a6804d04ecaf02987a075d5faf34877 diff --git a/autotest/release/pixi.toml b/autotest/release/pixi.toml index db689766..2395567c 100644 --- a/autotest/release/pixi.toml +++ b/autotest/release/pixi.toml @@ -5,6 +5,7 @@ platforms = ["win-64", "linux-64", "osx-arm64"] version = "0.1.0.dev0" [dependencies] +gfortran = "15.*" meson = ">=1.7.0" ninja = "*" diff --git a/autotest/release/src/hello.f90 b/autotest/release/src/hello.f90 index e6ae5f90..58644cd5 100644 --- a/autotest/release/src/hello.f90 +++ b/autotest/release/src/hello.f90 @@ -1,6 +1,6 @@ ! Print the kind of a default real and, given an expected kind as the first ! argument, fail when they differ. The version line is updated by -! scripts/version.py through release.toml. +! modflow_devtools/release/version.py through release.toml. program hello implicit none character(len=*), parameter :: VERSION = '0.1.0.dev0 01/01/2026' diff --git a/autotest/test_release.py b/autotest/test_release.py index 3365559f..431f6706 100644 --- a/autotest/test_release.py +++ b/autotest/test_release.py @@ -1,22 +1,41 @@ +import json +import os import shutil import subprocess +import sys +import zipfile from datetime import UTC, datetime from pathlib import Path import pytest -from modflow_devtools.release import changelog, version +from modflow_devtools.release import changelog, package, version, workflow FIXTURE = Path(__file__).resolve().parent / "release" +IGNORE = shutil.ignore_patterns("builddir*", "bin", ".pixi") +HAS_CLIFF = bool(shutil.which("pipx") or shutil.which("uvx")) @pytest.fixture def repo(tmp_path): root = tmp_path / "hello" - shutil.copytree(FIXTURE, root, ignore=shutil.ignore_patterns("builddir*", "bin", ".pixi")) + shutil.copytree(FIXTURE, root, ignore=IGNORE) return root +def git(root, *args): + return subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +# version + + def test_get(repo): assert version.get_version(repo) == "0.1.0.dev0" @@ -30,6 +49,10 @@ def test_set_updates_every_target(repo): assert "VERSION = '1.2.3 11/10/2026'" in (repo / "src" / "hello.f90").read_text() +def test_files(repo): + assert version.files(repo) == ["meson.build", "pixi.toml", "src/hello.f90"] + + def test_set_keeps_line_endings(repo): path = repo / "src" / "hello.f90" path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) @@ -67,6 +90,31 @@ def test_version_txt(repo): assert (repo / "version.txt").read_text() == "1.0.4\n" +def test_only_project_version(repo): + """Only project()'s version keyword counts, not meson_version or another + call's version keyword, even with parentheses in strings and comments.""" + meson = repo / "meson.build" + text = meson.read_text().replace( + " license: 'CC0',", + " license: 'CC0', # a comment with a )\n license_files: [')(.txt'],", + ) + text += "\ndep = dependency(\n 'netcdf',\n version: '>=4.9',\n required: false,\n)\n" + meson.write_text(text) + assert version.get_version(repo) == "0.1.0.dev0" + version.set_version(repo, "1.2.3") + text = meson.read_text() + assert "version: '1.2.3'" in text + assert "version: '>=4.9'" in text + assert "meson_version: '>= 1.7.0'" in text + + +def test_no_project_version(repo): + meson = repo / "meson.build" + meson.write_text(meson.read_text().replace(" version: '0.1.0.dev0',\n", "")) + with pytest.raises(SystemExit): + version.get_version(repo) + + def test_without_pixi_or_config(repo): (repo / "pixi.toml").unlink() (repo / "release.toml").unlink() @@ -74,13 +122,17 @@ def test_without_pixi_or_config(repo): assert version.get_version(repo) == "2.0.0" -def test_missing_target_fails(repo): +def test_missing_target_changes_nothing(repo): path = repo / "src" / "hello.f90" path.write_text(path.read_text().replace("VERSION =", "V =")) + before = {p: p.read_bytes() for p in (repo / "meson.build", repo / "pixi.toml")} with pytest.raises(SystemExit): version.set_version(repo, "1.2.3") + assert {p: p.read_bytes() for p in before} == before +# changelog + CHANGELOG = """\ # Changelog @@ -106,16 +158,13 @@ def test_notes(tmp_path): changelog.notes(tmp_path, "1.0.0") -def git(root, *args): - subprocess.run( - ["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], - cwd=root, - check=True, - capture_output=True, - ) +def test_notes_title(tmp_path): + (tmp_path / "CHANGELOG.md").write_text(CHANGELOG) + notes = changelog.notes(tmp_path, "1.2.0", "Hello") + assert notes == "Hello version 1.2.0\n\n### Features\n\n* Something new\n" -@pytest.mark.skipif(shutil.which("git-cliff") is None, reason="needs git-cliff") +@pytest.mark.skipif(not HAS_CLIFF, reason="needs pipx or uvx") def test_update(repo): git(repo, "init", "-q", "-b", "main") git(repo, "add", "-A") @@ -143,3 +192,354 @@ def test_update(repo): assert text.count("# Changelog") == 1 assert text.index("## Version 0.3.0") < text.index("## Version 0.2.0") assert "Faster" in changelog.notes(repo, "0.3.0") + + +# package + + +def built(repo, *names): + (repo / "bin").mkdir() + for name in names: + path = repo / "bin" / name + path.write_text("exe") + path.chmod(0o755) + + +def test_package(repo, tmp_path): + built(repo, "hello", "hellodbl") + dist = tmp_path / "dist" + assert package.package(repo, "hello", dist, tag="linux") == "linux" + with zipfile.ZipFile(dist / "linux.zip") as z: + assert sorted(z.namelist()) == ["hello", "hellodbl"] + if sys.platform != "win32": + assert (z.getinfo("hello").external_attr >> 16) & 0o111 + with zipfile.ZipFile(dist / "hello_source.zip") as z: + names = z.namelist() + assert "meson.build" in names and "meson.options" in names and "src/hello.f90" in names + + +def test_package_no_source(repo, tmp_path): + built(repo, "hello") + package.package(repo, "hello", tmp_path / "dist", source_paths=[], tag="linux") + assert [p.name for p in (tmp_path / "dist").iterdir()] == ["linux.zip"] + + +def test_package_source_only_on_linux(repo, tmp_path): + built(repo, "hello.exe") + package.package(repo, "hello", tmp_path / "dist", tag="win64") + assert [p.name for p in (tmp_path / "dist").iterdir()] == ["win64.zip"] + + +def test_package_optional_meson_options(repo, tmp_path): + built(repo, "hello") + (repo / "meson.options").unlink() + package.package(repo, "hello", tmp_path / "dist", tag="linux") + with pytest.raises(SystemExit): + package.package(repo, "hello", tmp_path / "dist", ["meson.options"], tag="linux") + + +def test_package_tracked_source(repo, tmp_path): + built(repo, "hello") + git(repo, "init", "-q", "-b", "main") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "first") + (repo / "src" / "scratch.o").write_text("untracked") + package.package(repo, "hello", tmp_path / "dist", tag="linux") + with zipfile.ZipFile(tmp_path / "dist" / "hello_source.zip") as z: + assert "src/scratch.o" not in z.namelist() + + +def test_package_empty_bin(repo, tmp_path): + with pytest.raises(SystemExit): + package.package(repo, "hello", tmp_path / "dist", tag="linux") + + +# workflow, against a local origin and a fake gh + +FAKE_GH = """\ +import json, os, sys +path = os.environ["FAKE_GH_STATE"] +with open(path) as f: + state = json.load(f) +args = sys.argv[1:] +state["calls"].append(args) +def opt(name): + return args[args.index(name) + 1] +out, code = "", 0 +if args[:2] == ["pr", "list"]: + out = "\\n".join(p["state"] for p in state["prs"] if p["head"] == opt("--head")) +elif args[:2] == ["pr", "create"]: + state["prs"].append({"head": opt("-H"), "base": opt("-B"), "state": "OPEN"}) +elif args[:2] == ["release", "view"]: + release = state["releases"].get(args[2]) + if release is None: + sys.stderr.write("release not found\\n") + code = 1 + else: + out = json.dumps(release["isDraft"]) +elif args[:2] == ["release", "delete"]: + del state["releases"][args[2]] +elif args[:2] == ["release", "create"]: + files = [os.path.basename(a) for a in args[3 : args.index("-R")]] + with open(opt("--notes-file")) as f: + notes = f.read() + state["releases"][args[2]] = { + "isDraft": False, "files": files, "target": opt("--target"), + "title": opt("--title"), "notes": notes, + } +with open(path, "w") as f: + json.dump(state, f) +print(out) +sys.exit(code) +""" + + +class Gh: + """A fake gh on PATH, keeping pull requests and releases in a file.""" + + def __init__(self, tmp_path, monkeypatch): + bindir = tmp_path / "fakebin" + bindir.mkdir() + script = bindir / "fake_gh.py" + script.write_text(FAKE_GH) + gh = bindir / "gh" + gh.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n') + gh.chmod(0o755) + self.path = tmp_path / "gh.json" + self.save({"prs": [], "releases": {}, "calls": []}) + monkeypatch.setenv("FAKE_GH_STATE", str(self.path)) + monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}") + + def load(self): + return json.loads(self.path.read_text()) + + def save(self, state): + self.path.write_text(json.dumps(state)) + + def calls(self, *prefix): + return [c for c in self.load()["calls"] if c[: len(prefix)] == list(prefix)] + + +@pytest.fixture +def gh(tmp_path, monkeypatch): + # the workflow runs on Linux, and the real gh.exe would shadow a fake + # gh.bat on Windows + if sys.platform == "win32": + pytest.skip("fake gh needs a POSIX shell") + return Gh(tmp_path, monkeypatch) + + +@pytest.fixture +def origin(repo, tmp_path, monkeypatch): + """The fixture as a git repo with a release history, pushed to a bare + origin. changelog.update is stubbed so these tests don't need git-cliff.""" + bare = tmp_path / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(bare)], check=True) + git(repo, "init", "-q", "-b", "main") + git(repo, "remote", "add", "origin", str(bare)) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "feat: first") + git(repo, "tag", "v0.0.1") + git(repo, "commit", "-q", "--allow-empty", "-m", "fix: second") + git(repo, "push", "-q", "origin", "main", "--tags") + + def update(root, v, repository, long_name): + path = root / "CHANGELOG.md" + old = path.read_text() if path.is_file() else "# Changelog\n" + path.write_text(old + f"\n## Version {v} (2026-11-15)\n\n### Fixes\n\n* Second\n") + + monkeypatch.setattr(changelog, "update", update) + monkeypatch.setenv("GIT_AUTHOR_NAME", "t") + monkeypatch.setenv("GIT_AUTHOR_EMAIL", "t@t") + monkeypatch.setenv("GIT_COMMITTER_NAME", "t") + monkeypatch.setenv("GIT_COMMITTER_EMAIL", "t@t") + return repo + + +def remote_file(repo, ref, path): + return git(repo, "show", f"origin/{ref}:{path}") + + +def start(repo, event="workflow_dispatch", ref_name="main", v="0.1.0", develop=""): + sha = git(repo, "rev-parse", "HEAD") + return workflow.start(repo, event, ref_name, sha, v, "Hello", "o/hello", "main", develop) + + +def test_start_nothing_to_do(origin, gh): + assert start(origin, "push")["stage"] == "" + assert start(origin, "push", ref_name="feature")["stage"] == "" + assert start(origin, "pull_request")["stage"] == "" + + +@pytest.mark.parametrize( + "kwargs", + [{"ref_name": "feature"}, {"v": "0.1"}, {"v": ""}, {"v": "0.0.1"}, {"develop": "develop"}], + ids=["wrong-branch", "bad-version", "no-version", "tagged", "not-develop"], +) +def test_start_rejects(origin, gh, kwargs): + with pytest.raises(SystemExit): + start(origin, **kwargs) + + +def test_prepare(origin, gh): + (origin / "stray.txt").write_text("not for the release") + out = start(origin) + assert out == {"stage": "prepare", "ref": "release/v0.1.0", "version": "0.1.0"} + git(origin, "fetch", "-q", "origin") + assert "version: '0.1.0'" in remote_file(origin, "release/v0.1.0", "meson.build") + assert "## Version 0.1.0" in remote_file(origin, "release/v0.1.0", "CHANGELOG.md") + changed = git(origin, "show", "--name-only", "--format=", "origin/release/v0.1.0") + assert sorted(changed.split()) == ["CHANGELOG.md", "meson.build", "pixi.toml", "src/hello.f90"] + + # a rerun reuses the branch + head = git(origin, "rev-parse", "origin/release/v0.1.0") + git(origin, "switch", "-q", "main") + assert start(origin)["stage"] == "prepare" + git(origin, "fetch", "-q", "origin") + assert git(origin, "rev-parse", "origin/release/v0.1.0") == head + + # the release pull request is opened once + for _ in range(2): + workflow.finish_prepare(origin, "o/hello", "release/v0.1.0", "0.1.0", "main") + creates = gh.calls("pr", "create") + assert len(creates) == 1 + assert creates[0][creates[0].index("-B") + 1] == "main" + + +def merged_release(origin, v="0.1.0"): + """Simulate merging the release pull request into main.""" + start(origin, v=v) + git(origin, "switch", "-q", "main") + git(origin, "merge", "-q", "--ff-only", f"release/v{v}") + git(origin, "push", "-q", "origin", "main") + + +def dist(tmp_path, *names): + path = tmp_path / "dist" + path.mkdir(exist_ok=True) + for name in names: + (path / name).write_text("zip") + return path + + +ARCHIVES = ("linux.zip", "macarm.zip", "win64.zip", "hello_source.zip") +PLATFORMS = ["linux", "macarm", "win64"] + + +def test_start_publish(origin, gh): + merged_release(origin) + out = start(origin, "push") + assert out == {"stage": "publish", "ref": git(origin, "rev-parse", "HEAD"), "version": "0.1.0"} + + +def test_start_publish_needs_notes(origin, gh): + """An untagged release version without release notes fails before the build.""" + version.set_version(origin, "0.1.0") + git(origin, "commit", "-q", "-am", "chore: set version") + with pytest.raises(SystemExit, match=r"no CHANGELOG\.md"): + start(origin, "push") + + +def test_publish(origin, gh, tmp_path): + merged_release(origin) + (origin / "doc").mkdir() + (origin / "doc" / "guide.pdf").write_text("pdf") + sha = git(origin, "rev-parse", "HEAD") + d = dist(tmp_path, *ARCHIVES) + for _ in range(2): + workflow.finish( + origin, "publish", "o/hello", sha, "0.1.0", "hello", "Hello", "main", "", + d, PLATFORMS, ["doc/*.pdf"], + ) # fmt: skip + state = gh.load() + assert len(gh.calls("release", "create")) == 1 + release = state["releases"]["v0.1.0"] + assert sorted(release["files"]) == sorted([*ARCHIVES, "guide.pdf"]) + assert release["target"] == sha + assert release["title"] == "hello 0.1.0" + assert release["notes"].startswith("Hello version 0.1.0\n\n### Fixes") + assert "--latest" not in gh.calls("release", "create")[0] + + # the next version pull request is opened once, from post-release/v0.1.0 + creates = gh.calls("pr", "create") + assert len(creates) == 1 + assert creates[0][creates[0].index("-H") + 1] == "post-release/v0.1.0" + assert creates[0][creates[0].index("-B") + 1] == "main" + git(origin, "fetch", "-q", "origin") + meson = remote_file(origin, "post-release/v0.1.0", "meson.build") + assert "version: '0.2.0.dev0'" in meson + + +def test_publish_dual(origin, gh, tmp_path): + merged_release(origin) + sha = git(origin, "rev-parse", "HEAD") + d = dist(tmp_path, *ARCHIVES) + workflow.finish( + origin, "publish", "o/hello", sha, "0.1.0", "hello", "Hello", "main", "develop", + d, PLATFORMS, + ) # fmt: skip + create = gh.calls("pr", "create")[0] + assert create[create.index("-B") + 1] == "develop" + assert "merge commit" in create[create.index("--body") + 1] + + +def test_publish_replaces_draft(origin, gh, tmp_path): + merged_release(origin) + state = gh.load() + state["releases"]["v0.1.0"] = {"isDraft": True} + gh.save(state) + sha = git(origin, "rev-parse", "HEAD") + workflow.publish( + origin, "o/hello", sha, "0.1.0", "hello", "Hello", dist(tmp_path, *ARCHIVES), PLATFORMS + ) + assert len(gh.calls("release", "delete")) == 1 + assert not gh.load()["releases"]["v0.1.0"]["isDraft"] + + +def test_post_release_reuses_branch(origin, gh, tmp_path): + """A rerun after pushing the branch but failing to open the pull request.""" + merged_release(origin) + git(origin, "push", "-q", "origin", "HEAD:refs/heads/post-release/v0.1.0") + workflow.post_release(origin, "o/hello", "0.1.0", "main") + assert len(gh.calls("pr", "create")) == 1 + + +def test_post_release_after_merge(origin, gh): + state = gh.load() + state["prs"].append({"head": "post-release/v0.1.0", "base": "main", "state": "MERGED"}) + gh.save(state) + assert not workflow.post_release(origin, "o/hello", "0.1.0", "main") + assert not gh.calls("pr", "create") + + +@pytest.mark.parametrize( + "names,extra", + [ + (("linux.zip", "win64.zip"), []), + ((*ARCHIVES, "mac.zip"), []), + ((*ARCHIVES, "other.txt"), []), + (ARCHIVES, ["doc/*.pdf"]), + (ARCHIVES, ["*.zip"]), + ], + ids=["missing", "intel-mac", "unexpected", "no-match", "duplicate-name"], +) +def test_bad_assets(repo, tmp_path, names, extra): + (repo / "hello_source.zip").write_text("zip") + with pytest.raises(SystemExit): + workflow.assets(repo, dist(tmp_path, *names), "hello", PLATFORMS, extra) + + +def test_finish_bad_stage(origin, gh, tmp_path): + with pytest.raises(SystemExit): + workflow.finish( + origin, "", "o/hello", "x", "0.1.0", "hello", "Hello", "main", "", tmp_path, PLATFORMS + ) + + +def test_cli_outputs(origin, gh, tmp_path): + output = tmp_path / "github_output" + sha = git(origin, "rev-parse", "HEAD") + args = ["--root", str(origin), "--repository", "o/hello", "start", "--event", "push"] + args += ["--ref-name", "main", "--sha", sha, "--long-name", "Hello", "--output", str(output)] + assert workflow.main(args) == 0 + assert output.read_text() == "stage=\nref=\nversion=0.1.0.dev0\n" diff --git a/docs/examples/build.yml b/docs/examples/build.yml index 5a371149..f104b4ec 100644 --- a/docs/examples/build.yml +++ b/docs/examples/build.yml @@ -2,7 +2,8 @@ # .github/workflows/build.yml and adapt it. It runs on pushes, pull requests # and a schedule, and is also called by the release workflow, which sets # `ref` and `release`. Release builds must upload one archive per platform -# with the package action (`upload: true`). +# with the package action (`upload: true`). gfortran comes from the pixi +# environment, so pixi.toml must depend on it, e.g. gfortran = "15.*". name: build on: push: @@ -37,11 +38,11 @@ jobs: include: # release builds, one per platform - {os: ubuntu-22.04, compiler: intel, version: "2025.0", release: true} - - {os: macos-15, compiler: gcc, version: "13", xcode-version: "16.4", release: true} + - {os: macos-15, compiler: gcc, xcode-version: "16.4", release: true} - {os: windows-2022, compiler: intel, version: "2025.0", release: true} # extra coverage - - {os: ubuntu-22.04, compiler: gcc, version: "13"} - - {os: windows-2022, compiler: gcc, version: "13"} + - {os: ubuntu-22.04, compiler: gcc} + - {os: windows-2022, compiler: gcc} defaults: run: shell: bash @@ -73,12 +74,12 @@ jobs: - name: Check linkage uses: MODFLOW-ORG/modflow-devtools/actions/check-linkage@1.10.0 + # package every build to catch problems early, upload release builds - name: Package - if: inputs.release && matrix.release uses: MODFLOW-ORG/modflow-devtools/actions/package@1.10.0 with: name: mfusg - upload: true + upload: ${{ inputs.release && matrix.release }} - name: Upload build logs if: failure() diff --git a/docs/examples/release.yml b/docs/examples/release.yml index 24564cbb..3083315b 100644 --- a/docs/examples/release.yml +++ b/docs/examples/release.yml @@ -67,3 +67,6 @@ jobs: version: ${{ needs.start.outputs.version }} name: mfusg long-name: MODFLOW-USG + # other files to attach, relative to the checkout + # extra-assets: | + # doc/*.pdf diff --git a/docs/md/release.md b/docs/md/release.md index cc7a7e58..5d3bd4dc 100644 --- a/docs/md/release.md +++ b/docs/md/release.md @@ -15,6 +15,8 @@ Shared CI and release tooling for MODFLOW program repositories. - [Release workflow](#release-workflow) - [Branching models](#branching-models) - [Making a release](#making-a-release) + - [Rerunning a release](#rerunning-a-release) + - [Adopting the release workflow](#adopting-the-release-workflow) - [Release assets](#release-assets) - [Actions](#actions) - [Versioning](#versioning) @@ -27,10 +29,10 @@ Shared CI and release tooling for MODFLOW program repositories. `modflow-devtools` provides - composite actions ([`actions/`](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/actions)) for building, checking, packaging and releasing programs -- the `modflow_devtools.release` modules the actions run, for versioning and changelog generation +- the `modflow_devtools.release` modules the actions run, for versioning, changelog generation, packaging, and the release workflow's stages - example workflows ([`docs/examples/`](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/docs/examples)) to copy into program repositories -Each program repository owns its workflows. Builds and tests differ between programs, so the build workflow is the repository's own, and it's short since the involved parts (compiler setup, static runtime linking on macOS, linkage checks, packaging) are actions. The release procedure should be the same everywhere, so its logic lives in the `release-start` and `release-finish` actions, and a program's release workflow just wires them up around its build workflow. +Each program repository owns its workflows. Builds and tests differ between programs, so the build workflow is the repository's own, and it's short since the involved parts (compiler setup, static runtime linking on macOS, linkage checks, packaging) are actions. The release procedure should be the same everywhere, so its logic lives in the `release-start` and `release-finish` actions (implemented in `modflow_devtools/release/workflow.py`, so it can be tested), and a program's release workflow just wires them up around its build workflow. Conventions every program should share, like the changelog format, static linking and the release asset layout, are fixed rather than configurable. The actions are meant for the simpler program repos (mfusg, mfnwt, mt3d-usgs, etc). MODFLOW 6 has a more complex release procedure and keeps its own workflows, but can use some of the actions, and its releases should follow the same [asset conventions](#release-assets). Releases following the conventions can be installed with the [Programs API](programs.md). @@ -53,13 +55,28 @@ test = "meson test --verbose --no-rebuild -C builddir" Executables must install to `bin/` at the repository root. Everything in `bin/` goes in the release archives, so nothing else should be installed there. If the program has a double precision build, its executables must have different names than the single precision ones (e.g. `mfusg` and `mfusgdbl`). -Don't set `link_language: 'fortran'` on executables. With Intel, it makes Meson append `-lifcore -limf` after `-static-intel`, so the executables link the Intel runtime dynamically. The `check-linkage` action catches this. +gfortran comes from the pixi environment, i.e. from conda-forge, so `pixi.toml` must depend on it: -Compilers are installed with [setup-fortran](https://github.com/fortran-lang/setup-fortran), which also sets `CC` and `CXX`. Releases should be built with Intel `ifx` on Linux and Windows, and with GNU Fortran on macOS. +```toml +[dependencies] +gfortran = "15.*" +``` + +If gfortran is in another pixi environment, pass it to the `setup` action as `pixi-environment`, and run the build in that environment. + +Intel compilers are installed with [setup-fortran](https://github.com/fortran-lang/setup-fortran), which also sets `FC`, `CC` and `CXX`. Releases should be built with Intel `ifx` on Linux and Windows, and with gfortran on macOS. + +Executables must link the compiler runtime libraries statically, so users don't need the compiler installed. The `setup` action sets `LDFLAGS` for this: `-static-libgfortran -static-libgcc -static-libquadmath` for gfortran (`-static` on Windows), and `-static-intel` for Intel on Linux. On Windows, Intel builds need Meson's `b_vscrt` option, which `LDFLAGS` can't set, so `meson.build` must have + +```meson +default_options: ['b_vscrt=static_from_buildtype', 'buildtype=release'], +``` + +Don't set `link_language: 'fortran'` on executables. With Intel, it makes Meson append `-lifcore -limf` after `-static-intel`, so the executables link the Intel runtime dynamically. The `check-linkage` action catches this, and any other dynamically linked compiler runtime. ### Version -The version must be set in `meson.build`, either directly +The version must be set in `meson.build`'s `project()` call, either directly ```meson project( @@ -73,7 +90,7 @@ or read from a file with `version: files('version.txt')`. Release versions are `major.minor.patch`. Between releases, the repository holds the next minor version with a `.devN` suffix, where `N` is the last release's patch number as in MODFLOW 6, e.g. `1.6.0.dev0` after `1.5.0` and `1.6.0.dev2` after `1.5.2`. Programs with `major.minor` versions should switch to `major.minor.0` at their next release. -If `pixi.toml` has a `version`, it is kept in sync with `meson.build`. Other files containing the version, e.g. a version string in the source code, can be listed in `release.toml` at the repository root: +Only `project()`'s `version` keyword counts, so e.g. a `dependency()`'s `version` doesn't. If `pixi.toml` has a `version`, it is kept in sync with `meson.build`. Other files containing the version, e.g. a version string in the source code, can be listed in `release.toml` at the repository root: ```toml [[version-files]] @@ -89,13 +106,16 @@ To get or set the version locally: ```shell python -m modflow_devtools.release.version --root ../mfusg --get python -m modflow_devtools.release.version --root ../mfusg --set 1.6.0.dev0 +python -m modflow_devtools.release.version --root ../mfusg --files ``` +Setting the version checks every file before writing any, so if one file has no version, or more than one, nothing changes. + ### Commits Release notes are generated with [git-cliff](https://git-cliff.org/) from [conventional commits](https://www.conventionalcommits.org/) since the last release. Only `feat`, `fix` and `perf` commits are included, and commits scoped `ci` (e.g. `fix(ci)`) are skipped. -The repository may provide its own `cliff.toml`, otherwise the [shared one](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/modflow_devtools/release/cliff.toml) is used. If there is no `CHANGELOG.md`, one is created at the first release. +Every program uses the [shared git-cliff template](https://github.com/MODFLOW-ORG/modflow-devtools/blob/develop/modflow_devtools/release/cliff.toml), so changelogs look the same everywhere and the release notes can be found by their `## Version X.Y.Z` headings. Repositories can't supply their own. git-cliff runs at a version pinned in `modflow_devtools/release/changelog.py`, with `pipx run` or `uvx`, so nothing needs installing. If there is no `CHANGELOG.md`, one is created at the first release, from the commits since the last `vX.Y.Z` tag, or the whole history if there is none. ### Repository settings @@ -112,8 +132,9 @@ Copy [`docs/examples/build.yml`](https://github.com/MODFLOW-ORG/modflow-devtools - builds single and double precision - runs the tests - checks the executables' linkage with the `check-linkage` action +- packages the executables with the `package` action -The build workflow must also accept `workflow_call` with inputs `ref` (what to check out) and `release` (whether to upload archives), since the release workflow calls it. In a release build, one job per platform must run the `package` action with `upload: true`. The example marks those matrix entries `release: true`. +The build workflow must also accept `workflow_call` with inputs `ref` (what to check out) and `release` (whether to upload archives), since the release workflow calls it. In a release build, one job per platform must run the `package` action with `upload: true`. The example marks those matrix entries `release: true`, and packages every build so packaging problems show up before a release. ## Release workflow @@ -137,6 +158,7 @@ Copy [`docs/examples/release.yml`](https://github.com/MODFLOW-ORG/modflow-devtoo - `long-name`: program long name, used in the release notes (required) - `main-branch`, `develop-branch`: as for `release-start` - `platforms`: OS tags that must each have an archive (default `linux macarm win64`) +- `extra-assets`: newline-separated globs, relative to the checkout, of other files to attach to the release, e.g. `doc/*.pdf`. Each must match a file. To attach files built in another job, download them into the checkout in a step before `release-finish`. ### Branching models @@ -152,22 +174,34 @@ In the dual model, the pull request that sets the next development version also To make a release: 1. Run the release workflow from the development branch (*Actions* -> *release* -> *Run workflow*) with the version to release, e.g. `1.5.2`. The workflow - - creates branch `release/v1.5.2` + - creates branch `release/v1.5.2`, or builds it as is if it exists - sets the version - updates `CHANGELOG.md` - builds and tests on each platform - opens a pull request into `main` 2. Review the pull request. The version's section in `CHANGELOG.md` becomes the release notes, so edit it on the release branch if needed. 3. Merge the pull request. If the version on `main` is a release version without a tag, the workflow + - checks that `CHANGELOG.md` has a section for the version, failing before the build if not - builds and tests on each platform again - - publishes release `v1.5.2` with the archives, targeting the merge commit - - opens a pull request setting the next development version (e.g. `1.6.0.dev2`) on the development branch + - publishes release `v1.5.2` with the archives and any extra assets, targeting the merge commit + - opens a pull request from branch `post-release/v1.5.2` setting the next development version (e.g. `1.6.0.dev2`) on the development branch 4. Merge that pull request. On any other push to `main`, `release-start` finds nothing to do and the other jobs are skipped. **Note:** pull requests created with the workflow token don't trigger other workflows, so the build workflow doesn't run on them. The release workflow already built and tested the release branch in step 1. To run the build workflow anyway, close and reopen the pull request. +### Rerunning a release + +Each step checks whether it's already done, so a failed release can be rerun: + +- In step 1, rerunning the workflow, or dispatching it again with the same version, reuses the existing `release/vX.Y.Z` branch, including any fixes pushed to it, and opens the pull request if there isn't one. To start over, delete the branch. +- In step 3, use *Re-run failed jobs*. A published release isn't published again, a draft left by a failed upload is replaced, and the next version pull request is only opened if there isn't an open or merged one from `post-release/vX.Y.Z`. Rerunning *all* jobs does nothing once the tag exists, since `release-start` then finds nothing to release. + +### Adopting the release workflow + +`release-start` publishes any untagged release version it finds on the main branch, so before adding the release workflow, make sure the version there is either tagged `vX.Y.Z` or a development version. Otherwise the first push to the main branch tries to release it, and fails if `CHANGELOG.md` has no section for it. Tag the last release on the commit it was made from, if it isn't already, so the first changelog only covers changes since then. + ## Release assets Program releases share a common layout, so the [executables](https://github.com/MODFLOW-ORG/executables) distribution and [modflow-devtools](https://github.com/MODFLOW-ORG/modflow-devtools) can treat every program the same way: @@ -176,19 +210,20 @@ Program releases share a common layout, so the [executables](https://github.com/ - one archive per platform, named for the [OS tag](ostags.md): `linux.zip`, `macarm.zip`, and `win64.zip` - executables at the archive root, no enclosing directory, double precision executables included if any - optionally, `_source.zip` -- no dynamic linking to compiler runtime libraries, i.e. Intel's on Linux or GNU's on macOS +- optionally, extra assets like documentation, named differently from the archives +- no dynamic linking to compiler runtime libraries on any platform: Intel's, GNU's (gfortran, quadmath, OpenMP, and except on Linux, where they're system libraries, the gcc and C++ runtimes), or the Microsoft Visual C++ runtime -The `package` action produces this layout, and `release-finish` refuses to publish missing or unexpected archives. +The `package` action produces this layout the same way on every platform, `check-linkage` enforces the static linking, and `release-finish` refuses to publish missing or unexpected archives. -Intel macOS (`mac`) is no longer supported. +Intel macOS (`mac`) is not supported. ## Actions -- `actions/setup`: set up the compiler and pixi, and on macOS, Xcode and static GCC runtime linking -- `actions/check-linkage`: list each executable's shared library dependencies and fail on compiler runtime libraries, and on macOS, check for arm64 +- `actions/setup`: set up the compiler (Intel with setup-fortran, gfortran from pixi), pixi, optionally Xcode, and static runtime linking +- `actions/check-linkage`: list each executable's shared library dependencies and fail on compiler runtime libraries on every platform, and on macOS, check for arm64 - `actions/package`: zip `bin/` as `.zip`, and on Linux the source as `_source.zip`, optionally uploading them as artifact `dist-` - `actions/release-start`: prepare the release branch, or check whether `main` needs releasing -- `actions/release-finish`: open the release pull request, or publish the release and open the next version pull request +- `actions/release-finish`: open the release pull request, or publish the release with any extra assets and open the next version pull request - `actions/version`: get or set the version, or get the next development version - `actions/changelog`: update `CHANGELOG.md` with git-cliff, or get a version's release notes @@ -200,7 +235,9 @@ The actions are versioned with `modflow-devtools`. Program repositories referenc ## Testing -`.github/workflows/actions.yml` runs the script tests, lints the example workflows, and runs the actions from the commit under test on a small Fortran program in `autotest/release` on each release platform. It runs only on changes to the actions, the scripts, the fixture or the examples. +`.github/workflows/actions.yml` runs the module tests, lints the example workflows, and runs the actions from the commit under test on a small Fortran program in `autotest/release` on each release platform. It runs only on changes to the actions, the modules, the fixture or the examples. + +The release workflow's stages are tested against a local bare git repository as `origin` and a fake `gh` that records pull requests and releases, including reruns of each stage. Those tests are skipped on Windows. To run the script tests locally: @@ -209,4 +246,4 @@ cd autotest pytest test_release.py ``` -The changelog tests require `git-cliff` and are skipped if it's not installed. +The changelog test runs git-cliff with `pipx` or `uvx`, and is skipped if neither is installed. diff --git a/modflow_devtools/release/__init__.py b/modflow_devtools/release/__init__.py index dc0bbf90..63901bc8 100644 --- a/modflow_devtools/release/__init__.py +++ b/modflow_devtools/release/__init__.py @@ -1,3 +1,4 @@ """Release tooling for MODFLOW program repositories, used by the composite actions in ``actions/``. The modules only use the standard library, so the -actions run them as scripts without installing the package.""" +actions run them with ``python -m`` and ``PYTHONPATH`` pointing at the +action's checkout of this repository, without installing the package.""" diff --git a/modflow_devtools/release/changelog.py b/modflow_devtools/release/changelog.py index b2df6f38..c8c8a7c6 100644 --- a/modflow_devtools/release/changelog.py +++ b/modflow_devtools/release/changelog.py @@ -1,10 +1,14 @@ """ Update CHANGELOG.md for a release, or print a release's notes from it. -``update`` runs git-cliff with the repo's cliff.toml, or with the shared -template (cliff.toml beside this script) filled in with the -repository and the program's long name. ``notes`` prints the section of +``update`` runs git-cliff with the shared template (cliff.toml beside this +module), filled in with the repository and the program's long name. Every +program uses the same template, so CHANGELOG.md looks the same everywhere and +``notes`` can find each version's section. ``notes`` prints the section of CHANGELOG.md for one version, which becomes the GitHub release notes. + +git-cliff runs at a pinned version with ``pipx run`` or ``uvx``, whichever is +available, so nothing needs installing beforehand. """ import argparse @@ -15,40 +19,36 @@ import tempfile from pathlib import Path +GIT_CLIFF_VERSION = "2.14.2" TEMPLATE = Path(__file__).resolve().parent / "cliff.toml" HEADING = re.compile(r"^## Version (\S+)") -def config(root, repository, long_name): - own = root / "cliff.toml" - if own.is_file(): - return own - text = TEMPLATE.read_text() - text = text.replace("__REPOSITORY__", repository).replace("__NAME__", long_name) - tmp = Path(tempfile.mkdtemp()) / "cliff.toml" - tmp.write_text(text) - return tmp +def git_cliff(): + """The command that runs the pinned git-cliff.""" + if pipx := shutil.which("pipx"): + return [pipx, "run", "--spec", f"git-cliff=={GIT_CLIFF_VERSION}", "git-cliff"] + if uvx := shutil.which("uvx"): + return [uvx, f"git-cliff@{GIT_CLIFF_VERSION}"] + sys.exit("running git-cliff needs pipx or uvx") def update(root, version, repository, long_name): - cliff = shutil.which("git-cliff") - if cliff is None: - sys.exit("git-cliff is not installed") changelog = root / "CHANGELOG.md" - cmd = [ - cliff, - "--config", - str(config(root, repository, long_name)), - "--unreleased", - "--tag", - f"v{version}", - ] - # --prepend keeps the existing file's header and earlier sections - cmd += ["--prepend", str(changelog)] if changelog.is_file() else ["-o", str(changelog)] - subprocess.run(cmd, cwd=root, check=True) + text = TEMPLATE.read_text() + text = text.replace("__REPOSITORY__", repository).replace("__NAME__", long_name) + with tempfile.TemporaryDirectory() as tmp: + config = Path(tmp) / "cliff.toml" + config.write_text(text) + cmd = [*git_cliff(), "--config", str(config), "--unreleased", "--tag", f"v{version}"] + # --prepend keeps the existing file's header and earlier sections + cmd += ["--prepend", str(changelog)] if changelog.is_file() else ["-o", str(changelog)] + subprocess.run(cmd, cwd=root, check=True) -def notes(root, version): +def notes(root, version, long_name=None): + """The section of CHANGELOG.md for the version, titled with the long + name if given.""" changelog = root / "CHANGELOG.md" if not changelog.is_file(): sys.exit("no CHANGELOG.md") @@ -64,7 +64,8 @@ def notes(root, version): lines.append(line) if not found: sys.exit(f"no section for version {version} in CHANGELOG.md") - return "\n".join(lines).strip() + "\n" + body = "\n".join(lines).strip() + "\n" + return f"{long_name} version {version}\n\n{body}" if long_name else body def main(argv=None): @@ -77,12 +78,13 @@ def main(argv=None): up.add_argument("--long-name", required=True) no = sub.add_parser("notes", help="print one version's section") no.add_argument("--version", required=True) + no.add_argument("--long-name", help="title the notes ' version '") args = parser.parse_args(argv) root = args.root.resolve() if args.command == "update": update(root, args.version, args.repository, args.long_name) else: - sys.stdout.write(notes(root, args.version)) + sys.stdout.write(notes(root, args.version, args.long_name)) return 0 diff --git a/modflow_devtools/release/cliff.toml b/modflow_devtools/release/cliff.toml index 29d4d791..1b7fa108 100644 --- a/modflow_devtools/release/cliff.toml +++ b/modflow_devtools/release/cliff.toml @@ -1,7 +1,8 @@ -# Shared git-cliff template for CHANGELOG.md and the release notes. Used when -# a program repo has no cliff.toml of its own; changelog.py fills in -# __REPOSITORY__ (owner/repo) and __NAME__ (the program's long name). Only -# features, fixes, and performance changes are listed. +# git-cliff template for CHANGELOG.md and the release notes, shared by every +# program repo. changelog.py fills in __REPOSITORY__ (owner/repo) and __NAME__ +# (the program's long name), and its notes command expects the +# "## Version X.Y.Z" headings below. Only features, fixes, and performance +# changes are listed. [changelog] header = """ diff --git a/modflow_devtools/release/package.py b/modflow_devtools/release/package.py new file mode 100644 index 00000000..b318a2bc --- /dev/null +++ b/modflow_devtools/release/package.py @@ -0,0 +1,106 @@ +""" +Zip a program's release archives, the same way on every platform. + +Everything under bin/ goes in .zip, at the archive root (bin/mfusg +becomes mfusg). On Linux, the source also goes in _source.zip, with +paths relative to the repo root. When the repo is a git checkout, only +tracked files go in the source archive. Prints the OS tag; the archive +contents go to stderr. +""" + +import argparse +import platform +import subprocess +import sys +import zipfile +from pathlib import Path + +# (sys.platform, machine) -> OS tag; Intel macOS (mac) isn't supported +OSTAGS = { + ("linux", "x86_64"): "linux", + ("darwin", "arm64"): "macarm", + ("win32", "amd64"): "win64", +} +DEFAULT_SOURCE = ["meson.build", "meson.options", "src"] + + +def ostag(): + key = (sys.platform, platform.machine().lower()) + if key not in OSTAGS: + sys.exit(f"no OS tag for {key[0]} {key[1]}") + return OSTAGS[key] + + +def zip_files(archive, files, base): + """Zip the files with paths relative to base. ZipFile.write keeps the + file mode, so executables stay executable.""" + archive.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z: + for path in sorted(files): + name = path.relative_to(base).as_posix() + z.write(path, name) + print(f"{archive.name}: {name}", file=sys.stderr) + + +def tracked(root, paths): + """Tracked files under the paths, or None if root isn't a git checkout.""" + try: + out = subprocess.run( + ["git", "ls-files", "-z", "--", *paths], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout + except (OSError, subprocess.CalledProcessError): + return None + return [root / p for p in out.split("\0") if p] + + +def source_files(root, paths): + """Files under the paths. Missing paths are an error, except optional + ones in the default list (meson.options).""" + if paths is None: + paths = [p for p in DEFAULT_SOURCE if (root / p).exists() or p != "meson.options"] + missing = [p for p in paths if not (root / p).exists()] + if missing: + sys.exit(f"source paths not found: {' '.join(missing)}") + files = tracked(root, paths) + if files is None: + files = [] + for p in paths: + path = root / p + files += [f for f in path.rglob("*") if f.is_file()] if path.is_dir() else [path] + return [f for f in files if f.is_file()] + + +def package(root, name, dist, source_paths=None, tag=None): + tag = tag or ostag() + bindir = root / "bin" + exes = [f for f in bindir.rglob("*") if f.is_file()] if bindir.is_dir() else [] + if not exes: + sys.exit(f"nothing in {bindir}") + zip_files(dist / f"{tag}.zip", exes, bindir) + if tag == "linux" and source_paths != []: + zip_files(dist / f"{name}_source.zip", source_files(root, source_paths), root) + return tag + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="repo root") + parser.add_argument("--name", required=True, help="program name, for the source archive") + parser.add_argument("--dist", type=Path, required=True, help="directory to write archives to") + parser.add_argument( + "--source-paths", + nargs="*", + help="paths for the source archive; default meson.build, meson.options " + "(if present) and src; none to skip it", + ) + args = parser.parse_args(argv) + print(package(args.root.resolve(), args.name, args.dist.resolve(), args.source_paths)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modflow_devtools/release/version.py b/modflow_devtools/release/version.py index 3390ddcf..db46a712 100644 --- a/modflow_devtools/release/version.py +++ b/modflow_devtools/release/version.py @@ -1,10 +1,11 @@ """ Get or set a program's version. -The version lives in meson.build (``version: 'X.Y.Z'``), or in version.txt -when meson.build reads it with ``version: files('version.txt')``. Setting the -version also updates ``version = "..."`` in pixi.toml, when present, and any -extra files listed in release.toml: +The version lives in the ``version`` keyword of meson.build's ``project()`` +call (``version: 'X.Y.Z'``), or in version.txt when the project reads it with +``version: files('version.txt')``. Setting the version also updates +``version = "..."`` in pixi.toml, when present, and any extra files listed in +release.toml: [[version-files]] path = "src/mfusg.f" @@ -13,7 +14,8 @@ # optional; {version} and {date} (UTC) are available format = "{version} {date:%m/%d/%Y}" -Release versions are X.Y.Z, development versions X.Y.Z.devN. +Every file is checked before any is written, so a failed update changes +nothing. Release versions are X.Y.Z, development versions X.Y.Z.devN. """ import argparse @@ -27,8 +29,11 @@ RELEASE = re.compile(r"^\d+\.\d+\.\d+$") DEVELOP = re.compile(r"^\d+\.\d+\.\d+\.dev\d+$") -MESON_LITERAL = re.compile(r"^\s*version\s*:\s*'([^']*)'", re.M) -MESON_FILE = re.compile(r"^\s*version\s*:\s*files\(\s*'([^']*)'\s*\)", re.M) +PROJECT = re.compile(r"^\s*project\s*\(", re.M) +# \b keeps meson_version from matching +MESON_LITERAL = re.compile(r"\bversion\s*:\s*'([^']*)'") +MESON_FILE = re.compile(r"\bversion\s*:\s*files\(\s*'([^']*)'\s*\)") +VERSION_TXT = re.compile(r"\A\s*(\S+)\s*\Z") PIXI = re.compile(r'^version\s*=\s*"([^"]*)"', re.M) @@ -37,6 +42,12 @@ class Target: path: Path pattern: re.Pattern format: str = "{version}" + # the part of the file to search, (start, end); None for all of it + span: tuple[int, int] | None = None + + def matches(self, text): + start, end = self.span or (0, len(text)) + return list(self.pattern.finditer(text, start, end)) def read(path): @@ -49,18 +60,53 @@ def write(path, text): f.write(text) +def project_span(text): + """The span of meson.build's project(...) call: from its opening to its + closing parenthesis, skipping strings and comments.""" + match = PROJECT.search(text) + if match is None: + return None + depth, i, n = 1, match.end(), len(text) + while i < n: + if text.startswith("'''", i): + close = text.find("'''", i + 3) + i = n if close < 0 else close + 3 + continue + c = text[i] + if c == "'": + # meson strings can't span lines, and escape with backslash + i += 1 + while i < n and text[i] not in "'\n": + i += 2 if text[i] == "\\" else 1 + elif c == "#": + close = text.find("\n", i) + i = n if close < 0 else close + continue + elif c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return match.start(), i + 1 + i += 1 + return None + + def source(root): """The file that holds the version, and a pattern matching it.""" meson = root / "meson.build" if not meson.is_file(): sys.exit(f"no meson.build in {root}") text = read(meson) - match = MESON_FILE.search(text) + span = project_span(text) + if span is None: + sys.exit("no project() in meson.build") + match = MESON_FILE.search(text, *span) if match: - return Target(root / match.group(1), re.compile(r"\A\s*(\S+)\s*\Z")) - if MESON_LITERAL.search(text): - return Target(meson, MESON_LITERAL) - sys.exit("no version in meson.build") + return Target(root / match.group(1), VERSION_TXT) + if MESON_LITERAL.search(text, *span): + return Target(meson, MESON_LITERAL, span=span) + sys.exit("no version in meson.build's project()") def load_config(root): @@ -85,12 +131,17 @@ def targets(root): return found +def files(root): + """Paths of every file the version is written to, relative to root.""" + return [t.path.relative_to(root).as_posix() for t in targets(root)] + + def get_version(root): target = source(root) - match = target.pattern.search(read(target.path)) - if match is None: + matches = target.matches(read(target.path)) + if not matches: sys.exit(f"no version in {target.path.name}") - return match.group(1) + return matches[0].group(1) def next_version(version): @@ -105,17 +156,22 @@ def set_version(root, version, date=None): if not (RELEASE.match(version) or DEVELOP.match(version)): sys.exit(f"{version} is not X.Y.Z or X.Y.Z.devN") date = date or datetime.now(UTC) + # check every target before writing any + updates = [] for target in targets(root): + name = target.path.relative_to(root).as_posix() + if not target.path.is_file(): + sys.exit(f"no file {name}") text = read(target.path) - matches = list(target.pattern.finditer(text)) + matches = target.matches(text) if len(matches) != 1: - name = target.path.relative_to(root) sys.exit(f"expected one version in {name}, found {len(matches)}") - match = matches[0] value = target.format.format(version=version, date=date) - start, end = match.span(1) - write(target.path, text[:start] + value + text[end:]) - print(f"{target.path.relative_to(root).as_posix()}: {value}") + start, end = matches[0].span(1) + updates.append((target.path, name, text[:start] + value + text[end:], value)) + for path, name, text, value in updates: + write(path, text) + print(f"{name}: {value}") def main(argv=None): @@ -132,6 +188,9 @@ def main(argv=None): help="exit 0 if the version is X.Y.Z, else 1", ) group.add_argument("--set", metavar="VERSION", help="set the version") + group.add_argument( + "--files", action="store_true", help="print the files the version is written to" + ) args = parser.parse_args(argv) root = args.root.resolve() if args.get: @@ -140,6 +199,8 @@ def main(argv=None): print(next_version(get_version(root))) elif args.is_release: return 0 if RELEASE.match(get_version(root)) else 1 + elif args.files: + print("\n".join(files(root))) else: set_version(root, args.set) return 0 diff --git a/modflow_devtools/release/workflow.py b/modflow_devtools/release/workflow.py new file mode 100644 index 00000000..b711c92a --- /dev/null +++ b/modflow_devtools/release/workflow.py @@ -0,0 +1,410 @@ +""" +The release workflow's stages, run by the release-start and release-finish +actions. + +``start`` selects the stage from the triggering event: + +- ``prepare``, on workflow_dispatch from the development branch: create + release/vX.Y.Z, set the version, update CHANGELOG.md, and push the branch. + If the branch already exists (e.g. a rerun after a failed build), it is + reused as is. +- ``publish``, on push to the main branch, if the version there is X.Y.Z, + untagged, and has a CHANGELOG.md section. +- nothing (empty stage) otherwise. + +``finish`` completes the stage after the build: + +- ``prepare``: open the release pull request into the main branch. +- ``publish``: check the archives, publish the GitHub release, and open a + pull request on post-release/vX.Y.Z setting the next development version on + the development branch. + +Each step checks whether it has already been done, so a failed run can be +rerun. +""" + +import argparse +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +from modflow_devtools.release import changelog, version + +BOT_NAME = "github-actions[bot]" +BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com" +ARCHIVES = {"linux.zip", "macarm.zip", "win64.zip"} +VERSION = re.compile(r"^\d+\.\d+\.\d+$") + + +def run(cmd, cwd, check=True): + """Run a command, capturing its output, and fail with its stderr.""" + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if check and result.returncode != 0: + sys.exit(f"{' '.join(cmd)} failed:\n{result.stderr.strip()}") + return result + + +def git(root, *args, check=True): + return run(["git", *args], root, check) + + +def gh(root, *args, check=True): + return run(["gh", *args], root, check) + + +def error(message): + print(f"::error::{message}") + + +def notice(message): + print(f"::notice::{message}") + + +def release_branch(v): + return f"release/v{v}" + + +def post_release_branch(v): + return f"post-release/v{v}" + + +def tagged(root, v): + return ( + git( + root, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/v{v}", check=False + ).returncode + == 0 + ) + + +def remote_branch(root, branch): + return ( + git( + root, + "ls-remote", + "--exit-code", + "--heads", + "origin", + f"refs/heads/{branch}", + check=False, + ).returncode + == 0 + ) + + +def commit(root, message, paths): + """Commit just the given paths, as the actions bot.""" + git(root, "add", "--", *paths) + git( + root, + "-c", + f"user.name={BOT_NAME}", + "-c", + f"user.email={BOT_EMAIL}", + "commit", + "-m", + message, + ) + + +def pr_states(root, repository, head): + """States (OPEN, MERGED, CLOSED) of the pull requests from a branch.""" + out = gh( + root, + "pr", + "list", + "-R", + repository, + "--head", + head, + "--state", + "all", + "--json", + "state", + "--jq", + ".[].state", + ) + return out.stdout.split() + + +def open_pr(root, repository, base, head, title, body): + """Open a pull request, unless one from the branch is open or merged.""" + states = pr_states(root, repository, head) + if "OPEN" in states or "MERGED" in states: + print(f"pull request from {head} already {'open' if 'OPEN' in states else 'merged'}") + return False + gh( + root, + "pr", + "create", + "-R", + repository, + "-B", + base, + "-H", + head, + "--title", + title, + "--body", + body, + ) + return True + + +def start(root, event, ref_name, sha, v, long_name, repository, main, develop=""): + """Select the stage, preparing the release branch for ``prepare``. + Returns the outputs: stage, ref and version.""" + develop = develop or main + if event == "workflow_dispatch": + if ref_name != develop: + sys.exit(f"start a release from {develop}, not {ref_name}") + if not VERSION.match(v or ""): + sys.exit(f"version '{v}' is not X.Y.Z") + if tagged(root, v): + sys.exit(f"tag v{v} already exists") + branch = release_branch(v) + if remote_branch(root, branch): + notice(f"{branch} exists, building it as is. Delete it to start over.") + else: + prepare(root, v, branch, repository, long_name) + return {"stage": "prepare", "ref": branch, "version": v} + if event == "push" and ref_name == main: + v = version.get_version(root) + if VERSION.match(v) and not tagged(root, v): + # fail before the build if there are no release notes + changelog.notes(root, v) + return {"stage": "publish", "ref": sha, "version": v} + print(f"version {v} on {main} is not an untagged release version, nothing to do") + return {"stage": "", "ref": "", "version": v or ""} + + +def prepare(root, v, branch, repository, long_name): + # git-cliff needs the history and tags since the last release + shallow = git(root, "rev-parse", "--is-shallow-repository").stdout.strip() == "true" + git(root, "fetch", "--quiet", *(["--unshallow"] if shallow else []), "--tags", "origin") + git(root, "switch", "-c", branch) + version.set_version(root, v) + changelog.update(root, v, repository, long_name) + commit( + root, + f"chore(release): set version to {v} and update changelog", + [*version.files(root), "CHANGELOG.md"], + ) + git(root, "push", "origin", branch) + + +def finish_prepare(root, repository, ref, v, main, develop=""): + develop = develop or main + body = ( + f"Review the {v} section of CHANGELOG.md, it becomes the release notes. " + f"Merging this publishes release v{v} and opens a pull request setting " + f"the next development version on {develop}." + ) + open_pr(root, repository, main, ref, f"chore(release): {v}", body) + + +def expand(root, patterns): + """Files matching each glob, relative to root. Each must match.""" + files = [] + for pattern in (p.strip() for p in patterns): + if not pattern: + continue + matches = sorted(p for p in root.glob(pattern) if p.is_file()) + if not matches: + sys.exit(f"extra asset pattern '{pattern}' matches no files") + files += matches + return files + + +def assets(root, dist, name, platforms, extra=()): + """The release's files: the archives in dist, each platform's present and + nothing unexpected, then the extra assets.""" + allowed = ARCHIVES | {f"{name}_source.zip"} + found = sorted(p for p in Path(dist).iterdir() if p.is_file()) if Path(dist).is_dir() else [] + ok = True + for p in platforms: + if not (Path(dist) / f"{p}.zip").is_file(): + error(f"missing {p}.zip") + ok = False + for path in found: + if path.name not in allowed: + error(f"unexpected file {path.name}") + ok = False + files = found + expand(root, extra) + names = [f.name for f in files] + for n in sorted({n for n in names if names.count(n) > 1}): + error(f"more than one asset named {n}") + ok = False + if not ok: + sys.exit("bad release assets") + return files + + +def release_state(root, repository, tag): + """None if there is no release for the tag, else 'draft' or 'published'.""" + result = gh( + root, + "release", + "view", + tag, + "-R", + repository, + "--json", + "isDraft", + "--jq", + ".isDraft", + check=False, + ) + if result.returncode != 0: + if "not found" in result.stderr.lower(): + return None + sys.exit(f"gh release view failed:\n{result.stderr.strip()}") + return "draft" if json.loads(result.stdout) else "published" + + +def publish(root, repository, ref, v, name, long_name, dist, platforms, extra=()): + files = assets(root, dist, name, platforms, extra) + notes = changelog.notes(root, v, long_name) + print(notes) + tag = f"v{v}" + state = release_state(root, repository, tag) + if state == "published": + print(f"release {tag} already published") + return False + if state == "draft": + # gh leaves a draft when an asset upload fails + print(f"deleting draft release {tag} from an earlier run") + gh(root, "release", "delete", tag, "-R", repository, "--yes") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "notes.md" + path.write_text(notes) + gh( + root, + "release", + "create", + tag, + *map(str, files), + "-R", + repository, + "--target", + ref, + "--title", + f"{name} {v}", + "--notes-file", + str(path), + ) + return True + + +def post_release(root, repository, v, main, develop=""): + """Open the pull request setting the next development version on the + development branch. In the dual-branch model, this also merges the main + branch into the development branch.""" + develop = develop or main + branch = post_release_branch(v) + states = pr_states(root, repository, branch) + if "OPEN" in states or "MERGED" in states: + print(f"pull request from {branch} already {'open' if 'OPEN' in states else 'merged'}") + return False + nxt = version.next_version(v) + if remote_branch(root, branch): + print(f"{branch} exists, opening a pull request from it") + else: + git(root, "switch", "-c", branch) + version.set_version(root, nxt) + commit(root, f"chore(release): set version to {nxt}", version.files(root)) + git(root, "push", "origin", branch) + body = f"Set the version on {develop} to {nxt} after the {v} release." + if develop != main: + body += ( + f" This also merges {main} into {develop}. Merge it with a merge commit, " + f"not a squash, so the v{v} tag is reachable from {develop}." + ) + open_pr(root, repository, develop, branch, f"chore(release): set version to {nxt}", body) + return True + + +def finish( + root, stage, repository, ref, v, name, long_name, main, develop, dist, platforms, extra=() +): + if stage == "prepare": + finish_prepare(root, repository, ref, v, main, develop) + elif stage == "publish": + publish(root, repository, ref, v, name, long_name, dist, platforms, extra) + post_release(root, repository, v, main, develop) + else: + sys.exit(f"unexpected stage '{stage}', expected prepare or publish") + + +def write_outputs(outputs, path=None): + for key, value in outputs.items(): + print(f"{key}={value}") + if path: + with Path(path).open("a", newline="\n") as f: + for key, value in outputs.items(): + f.write(f"{key}={value}\n") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="repo root") + parser.add_argument("--repository", required=True, help="owner/repo") + parser.add_argument("--main-branch", default="main") + parser.add_argument("--develop-branch", default="") + sub = parser.add_subparsers(dest="command", required=True) + s = sub.add_parser("start", help="select the stage, preparing the release branch") + s.add_argument("--event", required=True) + s.add_argument("--ref-name", required=True) + s.add_argument("--sha", required=True) + s.add_argument("--version", default="") + s.add_argument("--long-name", required=True) + s.add_argument("--output", help="file to append outputs to, e.g. $GITHUB_OUTPUT") + f = sub.add_parser("finish", help="open the release pull request, or publish") + f.add_argument("--stage", required=True) + f.add_argument("--ref", required=True) + f.add_argument("--version", required=True) + f.add_argument("--name", required=True) + f.add_argument("--long-name", required=True) + f.add_argument("--dist", type=Path, required=True, help="directory holding the archives") + f.add_argument("--platforms", nargs="+", default=["linux", "macarm", "win64"]) + f.add_argument("--extra-assets", nargs="*", default=[], help="globs relative to the repo root") + args = parser.parse_args(argv) + root = args.root.resolve() + if args.command == "start": + outputs = start( + root, + args.event, + args.ref_name, + args.sha, + args.version, + args.long_name, + args.repository, + args.main_branch, + args.develop_branch, + ) + write_outputs(outputs, args.output) + else: + finish( + root, + args.stage, + args.repository, + args.ref, + args.version, + args.name, + args.long_name, + args.main_branch, + args.develop_branch, + args.dist, + args.platforms, + args.extra_assets, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 003cd243184c8c27cf838aa41f56738881311403 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 15 Nov 2026 06:46:28 -0800 Subject: [PATCH 4/4] fix(release): assemble CHANGELOG.md without git-cliff's --prepend On Windows, --prepend repeated the changelog header. Insert the new section above the earlier ones in Python, and write the file with LF line endings. Co-Authored-By: Claude Opus 5.5 --- autotest/test_release.py | 1 + modflow_devtools/release/changelog.py | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/autotest/test_release.py b/autotest/test_release.py index 431f6706..e73b2b2a 100644 --- a/autotest/test_release.py +++ b/autotest/test_release.py @@ -190,6 +190,7 @@ def test_update(repo): changelog.update(repo, "0.3.0", "MODFLOW-ORG/hello", "Hello") text = (repo / "CHANGELOG.md").read_text() assert text.count("# Changelog") == 1 + assert b"\r\n" not in (repo / "CHANGELOG.md").read_bytes() assert text.index("## Version 0.3.0") < text.index("## Version 0.2.0") assert "Faster" in changelog.notes(repo, "0.3.0") diff --git a/modflow_devtools/release/changelog.py b/modflow_devtools/release/changelog.py index c8c8a7c6..55709b43 100644 --- a/modflow_devtools/release/changelog.py +++ b/modflow_devtools/release/changelog.py @@ -22,6 +22,7 @@ GIT_CLIFF_VERSION = "2.14.2" TEMPLATE = Path(__file__).resolve().parent / "cliff.toml" HEADING = re.compile(r"^## Version (\S+)") +SECTION = re.compile(r"^## Version ", re.M) def git_cliff(): @@ -34,6 +35,10 @@ def git_cliff(): def update(root, version, repository, long_name): + """Add the release's section to CHANGELOG.md, above the earlier ones, + creating the file with the template's header if needed. The file is + assembled here rather than with git-cliff's --prepend, which repeats the + header on Windows, and always written with LF line endings.""" changelog = root / "CHANGELOG.md" text = TEMPLATE.read_text() text = text.replace("__REPOSITORY__", repository).replace("__NAME__", long_name) @@ -41,9 +46,18 @@ def update(root, version, repository, long_name): config = Path(tmp) / "cliff.toml" config.write_text(text) cmd = [*git_cliff(), "--config", str(config), "--unreleased", "--tag", f"v{version}"] - # --prepend keeps the existing file's header and earlier sections - cmd += ["--prepend", str(changelog)] if changelog.is_file() else ["-o", str(changelog)] - subprocess.run(cmd, cwd=root, check=True) + if changelog.is_file(): + cmd += ["--strip", "header"] + # text mode turns any CRLF into LF + out = subprocess.run(cmd, cwd=root, check=True, stdout=subprocess.PIPE, text=True).stdout + if changelog.is_file(): + old = changelog.read_text() + match = SECTION.search(old) + head, rest = (old[: match.start()], old[match.start() :]) if match else (old, "") + parts = [head.rstrip("\n"), out.strip("\n"), rest.strip("\n")] + out = "\n\n".join(p for p in parts if p) + with changelog.open("w", newline="\n") as f: + f.write(out.strip("\n") + "\n") def notes(root, version, long_name=None):