diff --git a/create-release/README.md b/create-release/README.md new file mode 100644 index 0000000..7581d22 --- /dev/null +++ b/create-release/README.md @@ -0,0 +1,81 @@ +# Create Release + +This action creates a release on GitHub: +1. Archiving the current repository state as a tarball (`.tar.gz`). While GitHub also does this for releases those archives are unfortunately not stable and cannot be relied on. +2. Computing the checksum for the archived tarball. +3. Extracting the release notes from the changelog. + +## Action Inputs + +| Name | Description | Default | +| ---- | ----------- | ------- | +| `version` | Version to release. | **Required** | +| `branch` | Target branch to use for the release. | `${{ github.even.repository.default_branch` | +| `archive-name` | Name of the git archive to create. | `${{ github.event.repository.name }}-${{ inputs.version }}` | +| `output-directory` | Directory for the release artifacts. | `release` | +| `release-notes` | Name of the release notes to create. | `RELEASE_NOTES.md` | +| `token` | GitHub token to create the release.
Fine-grained PAT: `contents: write` | `${{ github.token }}` | + +## Sample Workflows + +### Basic Workflow + +```yaml +name: Create Release + +on: + workflow_dispatch: + inputs: + version: + description: The version to release. + required: true + +permissions: + contents: write + +jobs: + create: + runs-on: ubuntu-latest + steps: + - name: Create Release + uses: conda/actions/create-release + with: + version: ${{ inputs.version }} + branch: main +``` + +### Dynamic Branch Workflow + +```yaml +name: Create Release + +on: + workflow_dispatch: + inputs: + version: + description: The version to release. + required: true + +permissions: + contents: write + +jobs: + create: + runs-on: ubuntu-latest + steps: + - name: Get Branch + shell: python + run: | + from os import environ + from pathlib import Path + + # derive the branch from the version by dropping the `PATCH` and using `.x` + branch = "${{ inputs.version }}".rsplit(".", 1)[0] + Path(environ["GITHUB_ENV"]).write_text(f"BRANCH={branch}.x") + + - name: Create Release + uses: conda/actions/create-release + with: + version: ${{ inputs.version }} + branch: ${{ env.BRANCH }} +``` diff --git a/create-release/action.yml b/create-release/action.yml new file mode 100644 index 0000000..e3eaff6 --- /dev/null +++ b/create-release/action.yml @@ -0,0 +1,81 @@ +name: Create Release +description: Creates a release by archiving the source and creating a release on GitHub. +inputs: + version: + description: Version to release. + required: true + branch: + description: Target branch for the release. + default: ${{ github.event.repository.default_branch }} + archive-name: + description: Name of the git archive. + default: ${{ github.event.repository.name }}-${{ inputs.version }} + output-directory: + description: Directory for the release artifacts. + default: release + release-notes: + description: Path to the release notes. + default: RELEASE_NOTES.md + token: + description: 'GitHub token to create the release. Fine-grained PAT: `contents: write`' + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Checkout Source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Create Release Directory + shell: bash + run: mkdir -p ${{ inputs.output-directory }} + + - name: Archive Source + shell: bash + run: > + git archive + --prefix="${{ inputs.archive-name }}/" + --output="${{ inputs.output-directory }}/${{ inputs.archive-name }}.tar.gz" + HEAD + + - name: Compute Checksum + shell: bash + run: > + sha256sum "${{ inputs.output-directory }}/${{ inputs.archive-name }}.tar.gz" + | awk '{print $1}' + > "${{ inputs.output-directory }}/${{ inputs.archive-name }}.tar.gz.sha256sum" + + - name: Load Pip Cache + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: ~/.cache/pip + # invalidate the cache anytime a workflow changes + key: ${{ github.workflow }}-${{ hashFiles('.github/workflows/*') }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '>=3.7' + + - name: Pip List + shell: bash + run: pip list + + - name: Get Release Notes + shell: bash + run: > + python ${{ github.action_path }}/get_release_notes.py + --input="${{ inputs.changelog }}" + --version="${{ inputs.version }}" + --output="${{ inputs.output-directory }}/${{ inputs.release-notes }}" + + - name: Create Release + shell: bash + env: + GH_TOKEN: ${{ input.token }} + run: > + gh release create + --notes-file "${{ inputs.output-directory }}/${{ inputs.release-notes }}" + --target "${{ inputs.branch }}" + --title "${{ inputs.version }}" + "${{ inputs.version }}" + ${{ inputs.output-directory }}/${{ env.ARCHIVE_NAME }}.* diff --git a/create-release/get_release_notes.py b/create-release/get_release_notes.py new file mode 100644 index 0000000..32464a7 --- /dev/null +++ b/create-release/get_release_notes.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import re +from pathlib import Path +from argparse import ArgumentParser, ArgumentTypeError + + +def get_input(value: str) -> Path: + path = Path(value) + if not path.exists(): + raise ArgumentTypeError(f"{value!r} does not exist") + return path + + +def get_output(value: str) -> Path: + path = Path(value) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def get_version(value: str) -> str: + if not value: + raise ArgumentTypeError("must be a non-empty string") + return value + + +parser = ArgumentParser() +parser.add_argument("--input", required=True, type=get_input) +parser.add_argument("--output", required=True, type=get_output) +parser.add_argument("--version", required=True, type=get_version) +params = parser.parse_args() + +text = params.input.read_text() +pattern = re.compile( + rf""" + \n+ + ( + \#\#\s+ # markdown header + {re.escape(params.version)}\s+ # version number + \(\d\d\d\d-\d\d-\d\d\) # release date + )\n+ + ( + .+? # release notes + )\n+ + ( + \#\#\s+ # markdown header + \d+\.\d+\.\d+\s+ # version number + \(\d\d\d\d-\d\d-\d\d\) # release date + )\n+ + """, + flags=re.VERBOSE | re.DOTALL, +) +notes = match.group(2) if (match := pattern.search(text)) else "" +params.output.write_text(notes)