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..4e421082 --- /dev/null +++ b/.github/workflows/actions.yml @@ -0,0 +1,171 @@ +# 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: + paths: + - 'actions/**' + - 'modflow_devtools/release/**' + - 'autotest/release/**' + - 'autotest/test_release.py' + - 'docs/examples/**' + - '.github/workflows/actions.yml' + pull_request: + branches: + - main + - develop + 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 }} + 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 + + # the changelog tests run git-cliff with pipx + - name: Install + run: uv sync --group test + + - 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, xcode-version: "16.4"} + - {os: windows-2022, compiler: intel, version: "2025.0"} + - {os: ubuntu-22.04, compiler: gcc} + - {os: windows-2022, compiler: gcc} + 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..9d88cf81 --- /dev/null +++ b/actions/changelog/action.yml @@ -0,0 +1,63 @@ +name: Changelog +description: > + Prepend a release's features, fixes and performance changes to + 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 + 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 in the changelog header and the notes title + required: true +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: Update CHANGELOG.md + if: inputs.command == 'update' + shell: bash + working-directory: ${{ inputs.path }} + env: + PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. + VERSION: ${{ inputs.version }} + LONG_NAME: ${{ inputs.long-name }} + REPOSITORY: ${{ github.repository }} + run: | + "$PYTHON" -P -m modflow_devtools.release.changelog 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: + PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. + VERSION: ${{ inputs.version }} + LONG_NAME: ${{ inputs.long-name }} + run: | + file="$RUNNER_TEMP/notes.md" + "$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 new file mode 100644 index 00000000..d1ffc01b --- /dev/null +++ b/actions/check-linkage/action.yml @@ -0,0 +1,58 @@ +name: Check linkage +description: > + 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 +runs: + using: composite + steps: + - name: Check linkage + shell: bash + working-directory: ${{ inputs.path }} + 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) + 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) + 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 + 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 new file mode 100644 index 00000000..8ebb3a6e --- /dev/null +++ b/actions/package/action.yml @@ -0,0 +1,72 @@ +name: Package a MODFLOW program +description: > + 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 + default: . + name: + description: Program name, used for the source archive + required: true + source-paths: + 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 + platform in a release build + default: "false" +outputs: + ostag: + description: The platform tag + value: ${{ steps.package.outputs.ostag }} + dist: + description: Directory holding the archives + value: ${{ steps.package.outputs.dist }} +runs: + using: composite + steps: + - name: Setup Python + id: python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + update-environment: false + + - 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 }} + run: | + 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.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 new file mode 100644 index 00000000..88d92dcf --- /dev/null +++ b/actions/release-finish/action.yml @@ -0,0 +1,94 @@ +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 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 + 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 + 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: Setup Python + 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: Finish release + shell: bash + working-directory: ${{ inputs.path }} + env: + GH_TOKEN: ${{ inputs.github-token }} + PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. + DIST: ${{ runner.temp }}/dist + STAGE: ${{ inputs.stage }} + REF: ${{ inputs.ref }} + VERSION: ${{ inputs.version }} + NAME: ${{ inputs.name }} + LONG_NAME: ${{ inputs.long-name }} + MAIN_BRANCH: ${{ inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch }} + PLATFORMS: ${{ inputs.platforms }} + EXTRA_ASSETS: ${{ inputs.extra-assets }} + run: | + 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 new file mode 100644 index 00000000..b5cb7dd3 --- /dev/null +++ b/actions/release-start/action.yml @@ -0,0 +1,63 @@ +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, 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 + 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 changelog header + 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.start.outputs.stage }} + ref: + description: Branch or commit to build and release + value: ${{ steps.start.outputs.ref }} + version: + description: Version being released + value: ${{ steps.start.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: Start release + id: start + shell: bash + working-directory: ${{ inputs.path }} + env: + PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. + VERSION: ${{ inputs.version }} + LONG_NAME: ${{ inputs.long-name }} + MAIN_BRANCH: ${{ inputs.main-branch }} + DEVELOP_BRANCH: ${{ inputs.develop-branch }} + run: | + "$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 new file mode 100644 index 00000000..e38396d0 --- /dev/null +++ b/actions/setup/action.yml @@ -0,0 +1,103 @@ +name: Set up a MODFLOW program build +description: > + 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: gcc (gfortran from the pixi environment), intel or intel-classic + default: gcc + compiler-version: + 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 != 'gcc' + 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 + environments: ${{ inputs.pixi-environment }} + + - name: Set Xcode version (macOS) + if: runner.os == 'macOS' && inputs.xcode-version != '' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ inputs.xcode-version }} + + # 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: | + 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" + + - name: Show toolchain + shell: bash + working-directory: ${{ inputs.path }} + env: + COMPILER: ${{ inputs.compiler }} + PIXI_ENV: ${{ inputs.pixi-environment }} + run: | + 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 + 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 new file mode 100644 index 00000000..2f2f490c --- /dev/null +++ b/actions/version/action.yml @@ -0,0 +1,59 @@ +name: Program version +description: > + 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 + 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: + PYTHON: ${{ steps.python.outputs.python-path }} + PYTHONPATH: ${{ github.action_path }}/../.. + SET: ${{ inputs.set }} + NEXT: ${{ inputs.next }} + run: | + if [[ -n "$SET" ]]; then + "$PYTHON" -P -m modflow_devtools.release.version --set "$SET" + fi + version=$("$PYTHON" -P -m modflow_devtools.release.version --get) + echo "version=$version" >> "$GITHUB_OUTPUT" + 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" -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 new file mode 100644 index 00000000..0e8dd59f --- /dev/null +++ b/autotest/release/meson.build @@ -0,0 +1,34 @@ +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 +# 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, + 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..69f817d7 --- /dev/null +++ b/autotest/release/pixi.lock @@ -0,0 +1,1796 @@ +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/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 + - 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/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 + - 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/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 + 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/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 + 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/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 + 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/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 + 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/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 + 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/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 + 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/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 + license: LicenseRef-Public-Domain + 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 + 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/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 + 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/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 + 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/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 + 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/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 + 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/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 + 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/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 + 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/_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 + 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/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 + 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/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 + 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/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 + 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/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 + 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..2395567c --- /dev/null +++ b/autotest/release/pixi.toml @@ -0,0 +1,15 @@ +[workspace] +name = "hello" +channels = ["conda-forge"] +platforms = ["win-64", "linux-64", "osx-arm64"] +version = "0.1.0.dev0" + +[dependencies] +gfortran = "15.*" +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..58644cd5 --- /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 +! modflow_devtools/release/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..e73b2b2a --- /dev/null +++ b/autotest/test_release.py @@ -0,0 +1,546 @@ +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, 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=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" + + +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_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")) + 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_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() + version.set_version(repo, "2.0.0") + assert version.get_version(repo) == "2.0.0" + + +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 + +## 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 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(not HAS_CLIFF, reason="needs pipx or uvx") +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 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") + + +# 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 new file mode 100644 index 00000000..f104b4ec --- /dev/null +++ b/docs/examples/build.yml @@ -0,0 +1,90 @@ +# 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`). gfortran comes from the pixi +# environment, so pixi.toml must depend on it, e.g. gfortran = "15.*". +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, xcode-version: "16.4", release: true} + - {os: windows-2022, compiler: intel, version: "2025.0", release: true} + # extra coverage + - {os: ubuntu-22.04, compiler: gcc} + - {os: windows-2022, compiler: gcc} + 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 + + # package every build to catch problems early, upload release builds + - name: Package + uses: MODFLOW-ORG/modflow-devtools/actions/package@1.10.0 + with: + name: mfusg + upload: ${{ inputs.release && matrix.release }} + + - 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..3083315b --- /dev/null +++ b/docs/examples/release.yml @@ -0,0 +1,72 @@ +# 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 + # other files to attach, relative to the checkout + # extra-assets: | + # doc/*.pdf 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..5d3bd4dc --- /dev/null +++ b/docs/md/release.md @@ -0,0 +1,249 @@ +# 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) + - [Rerunning a release](#rerunning-a-release) + - [Adopting the release workflow](#adopting-the-release-workflow) +- [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, 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 (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). + +## 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`). + +gfortran comes from the pixi environment, i.e. from conda-forge, so `pixi.toml` must depend on it: + +```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`'s `project()` call, 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. + +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]] +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 +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. + +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 + +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 +- 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`, and packages every build so packaging problems show up before a release. + +## 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`) +- `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 + +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`, 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 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: + +- 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` +- 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 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 not supported. + +## Actions + +- `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 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 + +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 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: + +```shell +cd autotest +pytest test_release.py +``` + +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 new file mode 100644 index 00000000..63901bc8 --- /dev/null +++ b/modflow_devtools/release/__init__.py @@ -0,0 +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 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 new file mode 100644 index 00000000..55709b43 --- /dev/null +++ b/modflow_devtools/release/changelog.py @@ -0,0 +1,106 @@ +""" +Update CHANGELOG.md for a release, or print a release's notes from it. + +``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 +import re +import shutil +import subprocess +import sys +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+)") +SECTION = re.compile(r"^## Version ", re.M) + + +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): + """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) + 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}"] + 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): + """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") + 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") + body = "\n".join(lines).strip() + "\n" + return f"{long_name} version {version}\n\n{body}" if long_name else body + + +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) + 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, args.long_name)) + 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..1b7fa108 --- /dev/null +++ b/modflow_devtools/release/cliff.toml @@ -0,0 +1,42 @@ +# 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 = """ +# 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/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 new file mode 100644 index 00000000..db46a712 --- /dev/null +++ b/modflow_devtools/release/version.py @@ -0,0 +1,210 @@ +""" +Get or set a program's version. + +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" + # one capture group, holding the text to replace + pattern = "PARAMETER \\(VERSION='([^']*)'\\)" + # optional; {version} and {date} (UTC) are available + format = "{version} {date:%m/%d/%Y}" + +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 +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+$") + +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) + + +@dataclass +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): + 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 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) + 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), 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): + 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 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) + matches = target.matches(read(target.path)) + if not matches: + sys.exit(f"no version in {target.path.name}") + return matches[0].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) + # 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 = target.matches(text) + if len(matches) != 1: + sys.exit(f"expected one version in {name}, found {len(matches)}") + value = target.format.format(version=version, date=date) + 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): + 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") + 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: + 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 + elif args.files: + print("\n".join(files(root))) + else: + set_version(root, args.set) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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()) 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/*" ]