├── .github ├── actions │ ├── check-release │ │ └── action.yml │ ├── finalize-release │ │ └── action.yml │ ├── install-releaser │ │ └── action.yml │ ├── populate-release │ │ └── action.yml │ ├── prep-release │ │ └── action.yml │ └── publish-changelog │ │ └── action.yml ├── dependabot.yml ├── scripts │ ├── bump_tag.sh │ └── install-releaser.sh └── workflows │ ├── check-release.yml │ ├── enforce-label.yml │ ├── generate-changelog.yml │ ├── prep-release.yml │ ├── prep-self-release.yml │ ├── publish-changelog.yml │ ├── publish-release.yml │ ├── publish-self-release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── docs └── source │ ├── _static │ ├── custom.css │ └── images │ │ └── logo │ │ ├── favicon.svg │ │ └── logo.svg │ ├── conf.py │ ├── faq │ └── index.md │ ├── get_started │ ├── generate_changelog.md │ ├── index.md │ ├── making_release_from_releaser.md │ └── making_release_from_repo.md │ ├── how_to_guides │ ├── convert_repo_from_releaser.md │ ├── convert_repo_from_repo.md │ ├── index.md │ ├── maintain_fork.md │ └── write_config.md │ ├── images │ ├── draft_github_release.png │ ├── final_github_release.png │ ├── fork_fetch.png │ ├── generate_changelog.png │ ├── prep_release.png │ ├── prep_release_next_step.png │ ├── prep_release_repo.png │ ├── publish_release.png │ ├── publish_release_next_step.png │ └── publish_release_repo.png │ ├── index.md │ └── reference │ ├── api_docs.md │ ├── changelog.md │ ├── configuration.md │ ├── index.md │ ├── releaser_cli.md │ └── theory.md ├── example-workflows ├── full-release.yml ├── prep-release.yml ├── publish-changelog.yml └── publish-release.yml ├── jupyter_releaser ├── __init__.py ├── __main__.py ├── actions │ ├── __init__.py │ ├── common.py │ ├── finalize_release.py │ ├── generate_changelog.py │ ├── populate_release.py │ ├── prep_release.py │ └── publish_changelog.py ├── changelog.py ├── cli.py ├── lib.py ├── mock_github.py ├── npm.py ├── py.typed ├── python.py ├── schema.json ├── tee.py └── util.py ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── test_changelog.py ├── test_cli.py ├── test_functions.py ├── test_mock_github.py └── util.py /.github/actions/check-release/action.yml: -------------------------------------------------------------------------------- 1 | name: "Check Release" 2 | description: "Run through a dry run release cycle" 3 | inputs: 4 | token: 5 | description: "GitHub access token" 6 | required: true 7 | version_spec: 8 | description: "New Version Specifier" 9 | required: false 10 | default: "" 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | admin_check: 15 | description: "Check if the user is a repo admin" 16 | required: false 17 | default: "true" 18 | shell: 19 | description: "The shell being used for the action steps" 20 | required: false 21 | default: bash -eux {0} 22 | runs: 23 | using: "composite" 24 | steps: 25 | - shell: ${{ inputs.shell }} 26 | id: install-releaser 27 | run: | 28 | cd "${{ github.action_path }}/../../scripts" 29 | bash install-releaser.sh 30 | 31 | - id: prep-release 32 | shell: ${{ inputs.shell }} 33 | run: | 34 | export RH_DRY_RUN="true" 35 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 36 | export RH_VERSION_SPEC=${{ inputs.version_spec }} 37 | export RH_STEPS_TO_SKIP=${{ inputs.steps_to_skip }} 38 | export RH_ADMIN_CHECK=${{ inputs.admin_check }} 39 | python -m jupyter_releaser.actions.prep_release 40 | 41 | - id: populate-release 42 | shell: ${{ inputs.shell }} 43 | run: | 44 | export RH_DRY_RUN="true" 45 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 46 | export RH_RELEASE_URL=${{ steps.prep-release.outputs.release_url }} 47 | export RH_STEPS_TO_SKIP=${{ inputs.steps_to_skip }} 48 | export RH_ADMIN_CHECK=${{ inputs.admin_check }} 49 | export YARN_UNSAFE_HTTP_WHITELIST=0.0.0.0 50 | python -m jupyter_releaser.actions.populate_release 51 | 52 | - id: finalize-release 53 | shell: ${{ inputs.shell }} 54 | run: | 55 | export RH_DRY_RUN="true" 56 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 57 | export RH_RELEASE_URL=${{ steps.populate-release.outputs.release_url }} 58 | export RH_STEPS_TO_SKIP=${{ inputs.steps_to_skip }} 59 | export RH_ADMIN_CHECK=${{ inputs.admin_check }} 60 | python -m jupyter_releaser.actions.finalize_release 61 | -------------------------------------------------------------------------------- /.github/actions/finalize-release/action.yml: -------------------------------------------------------------------------------- 1 | name: "Finalize Release" 2 | description: "Publish assets and finalize GitHub release" 3 | inputs: 4 | token: 5 | description: "GitHub access token" 6 | required: true 7 | target: 8 | description: "The owner/repo GitHub target" 9 | required: false 10 | branch: 11 | description: "The target branch" 12 | required: false 13 | release_url: 14 | description: "The full url to the GitHub release page" 15 | required: false 16 | dry_run: 17 | description: "If set, do not push permanent changes" 18 | default: "false" 19 | required: false 20 | steps_to_skip: 21 | description: "Comma separated list of steps to skip" 22 | required: false 23 | admin_check: 24 | description: "Check if the user is a repo admin" 25 | required: false 26 | default: "true" 27 | shell: 28 | description: "The shell being used for the action steps" 29 | required: false 30 | default: bash -eux {0} 31 | 32 | outputs: 33 | release_url: 34 | description: "The html URL of the GitHub release" 35 | value: ${{ steps.finalize-release.outputs.release_url }} 36 | pr_url: 37 | description: "The html URL of the forwardport PR if applicable" 38 | value: ${{ steps.finalize-release.outputs.pr_url }} 39 | 40 | runs: 41 | using: "composite" 42 | steps: 43 | - name: install-releaser 44 | shell: ${{ inputs.shell }} 45 | run: | 46 | cd "${{ github.action_path }}/../../scripts" 47 | bash install-releaser.sh 48 | 49 | - id: finalize-release 50 | shell: ${{ inputs.shell }} 51 | run: | 52 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 53 | export GITHUB_ACTOR=${{ github.triggering_actor }} 54 | export RH_REPOSITORY=${{ inputs.target }} 55 | export RH_DRY_RUN=${{ inputs.dry_run }} 56 | export RH_RELEASE_URL=${{ inputs.release_url }} 57 | export RH_STEPS_TO_SKIP=${{ inputs.steps_to_skip }} 58 | export RH_BRANCH=${{ inputs.branch }} 59 | export RH_ADMIN_CHECK=${{ inputs.admin_check }} 60 | python -m jupyter_releaser.actions.finalize_release 61 | 62 | - if: ${{ success() }} 63 | shell: ${{ inputs.shell }} 64 | run: | 65 | echo "## Next Step" >> $GITHUB_STEP_SUMMARY 66 | echo "Verify the final release" >> $GITHUB_STEP_SUMMARY 67 | echo ${{ steps.finalize-release.outputs.release_url }} >> $GITHUB_STEP_SUMMARY 68 | if [ ! -z "${{ steps.finalize-release.outputs.pr_url }}" ]; then 69 | echo "Merge the forwardport PR" 70 | echo ${{ steps.finalize-release.outputs.pr_url }} 71 | echo "Merge the forwardport PR" >> $GITHUB_STEP_SUMMARY 72 | echo ${{ steps.finalize-release.outputs.pr_url }} >> $GITHUB_STEP_SUMMARY 73 | fi 74 | 75 | - if: ${{ failure() }} 76 | shell: ${{ inputs.shell }} 77 | run: | 78 | echo "## Failure Message" >> $GITHUB_STEP_SUMMARY 79 | echo ":x: Failed to Publish the Draft Release Url:" >> $GITHUB_STEP_SUMMARY 80 | echo ${{ inputs.release_url }} >> $GITHUB_STEP_SUMMARY 81 | -------------------------------------------------------------------------------- /.github/actions/install-releaser/action.yml: -------------------------------------------------------------------------------- 1 | name: "Install Releaser" 2 | description: "Ensure Releaser is Installed" 3 | runs: 4 | using: "composite" 5 | steps: 6 | - shell: bash 7 | id: install-releaser 8 | run: | 9 | cd "${{ github.action_path }}/../../scripts" 10 | bash install-releaser.sh 11 | -------------------------------------------------------------------------------- /.github/actions/populate-release/action.yml: -------------------------------------------------------------------------------- 1 | name: "Populate Release" 2 | description: "Populate the Draft GitHub Release" 3 | inputs: 4 | token: 5 | description: "GitHub access token" 6 | required: true 7 | target: 8 | description: "The owner/repo GitHub target" 9 | required: false 10 | branch: 11 | description: "The target branch" 12 | required: false 13 | release_url: 14 | description: "The full url to the GitHub release page" 15 | required: false 16 | dry_run: 17 | description: "If set, do not push permanent changes" 18 | default: "false" 19 | required: false 20 | steps_to_skip: 21 | description: "Comma separated list of steps to skip" 22 | required: false 23 | admin_check: 24 | description: "Check if the user is a repo admin" 25 | required: false 26 | default: "true" 27 | shell: 28 | description: "The shell being used for the action steps" 29 | required: false 30 | default: bash -eux {0} 31 | 32 | outputs: 33 | release_url: 34 | description: "The html URL of the draft GitHub release" 35 | value: ${{ steps.populate-release.outputs.release_url }} 36 | 37 | runs: 38 | using: "composite" 39 | steps: 40 | - name: install-releaser 41 | shell: ${{ inputs.shell }} 42 | run: | 43 | cd "${{ github.action_path }}/../../scripts" 44 | bash install-releaser.sh 45 | 46 | - id: populate-release 47 | shell: ${{ inputs.shell }} 48 | run: | 49 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 50 | export GITHUB_ACTOR=${{ github.triggering_actor }} 51 | export RH_REPOSITORY=${{ inputs.target }} 52 | export RH_DRY_RUN=${{ inputs.dry_run }} 53 | export RH_STEPS_TO_SKIP=${{ inputs.steps_to_skip }} 54 | export RH_RELEASE_URL=${{ inputs.release_url }} 55 | export RH_BRANCH=${{ inputs.branch }} 56 | export RH_ADMIN_CHECK=${{ inputs.admin_check }} 57 | python -m jupyter_releaser.actions.populate_release 58 | 59 | - if: ${{ failure() }} 60 | shell: ${{ inputs.shell }} 61 | run: | 62 | echo "## Failure Message" >> $GITHUB_STEP_SUMMARY 63 | echo ":x: Failed to Publish the Draft Release Url:" >> $GITHUB_STEP_SUMMARY 64 | echo ${{ steps.populate-release.outputs.release_url }} >> $GITHUB_STEP_SUMMARY 65 | -------------------------------------------------------------------------------- /.github/actions/prep-release/action.yml: -------------------------------------------------------------------------------- 1 | name: "Prep Release" 2 | description: "Start the Release Process" 3 | inputs: 4 | token: 5 | description: "GitHub access token" 6 | required: true 7 | target: 8 | description: "The owner/repo GitHub target" 9 | required: false 10 | version_spec: 11 | description: "New Version Specifier" 12 | default: "next" 13 | required: false 14 | branch: 15 | description: "The branch to target" 16 | required: false 17 | post_version_spec: 18 | description: "Post Version Specifier" 19 | required: false 20 | dry_run: 21 | description: "If set, do not make a PR" 22 | default: "false" 23 | required: false 24 | silent: 25 | description: "Set a placeholder in the changelog and don't publish the release." 26 | required: false 27 | since: 28 | description: "Use PRs with activity since this date or git reference" 29 | required: false 30 | since_last_stable: 31 | description: "Use PRs with activity since the last stable git tag" 32 | required: false 33 | admin_check: 34 | description: "Check if the user is a repo admin" 35 | required: false 36 | default: "true" 37 | shell: 38 | description: "The shell being used for the action steps" 39 | required: false 40 | default: bash -eux {0} 41 | outputs: 42 | release_url: 43 | description: "The html URL of the draft GitHub release" 44 | value: ${{ steps.prep-release.outputs.release_url }} 45 | runs: 46 | using: "composite" 47 | steps: 48 | - name: install-releaser 49 | shell: ${{ inputs.shell }} 50 | run: | 51 | cd "${{ github.action_path }}/../../scripts" 52 | bash install-releaser.sh 53 | 54 | - id: prep-release 55 | shell: ${{ inputs.shell }} 56 | run: | 57 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 58 | export GITHUB_ACTOR=${{ github.triggering_actor }} 59 | export RH_REPOSITORY=${{ inputs.target }} 60 | if [ ! -z ${{ inputs.branch }} ]; then 61 | export RH_BRANCH=${{ inputs.branch }} 62 | fi 63 | export RH_VERSION_SPEC=${{ inputs.version_spec }} 64 | export RH_POST_VERSION_SPEC=${{ inputs.post_version_spec }} 65 | export RH_DRY_RUN=${{ inputs.dry_run }} 66 | export RH_SILENT=${{ inputs.silent }} 67 | export RH_SINCE=${{ inputs.since }} 68 | export RH_SINCE_LAST_STABLE=${{ inputs.since_last_stable }} 69 | export RH_ADMIN_CHECK=${{ inputs.admin_check }} 70 | 71 | python -m jupyter_releaser.actions.prep_release 72 | 73 | - shell: ${{ inputs.shell }} 74 | run: | 75 | echo "## Next Step" >> $GITHUB_STEP_SUMMARY 76 | echo "(Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" >> $GITHUB_STEP_SUMMARY 77 | echo "Run Step 2: Publish Release workflow" 78 | -------------------------------------------------------------------------------- /.github/actions/publish-changelog/action.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Changelog" 2 | description: "Remove silent placeholder entries in the changelog file." 3 | inputs: 4 | token: 5 | description: "GitHub access token" 6 | required: true 7 | target: 8 | description: "The owner/repo GitHub target" 9 | required: false 10 | branch: 11 | description: "The branch to target" 12 | required: false 13 | dry_run: 14 | description: "If set, do not make a PR" 15 | default: "false" 16 | required: false 17 | shell: 18 | description: "The shell being used for the action steps" 19 | required: false 20 | default: bash -eux {0} 21 | outputs: 22 | pr_url: 23 | description: "The html URL of the draft GitHub release" 24 | value: ${{ steps.publish-changelog.outputs.pr_url }} 25 | runs: 26 | using: "composite" 27 | steps: 28 | - name: install-releaser 29 | shell: ${{ inputs.shell }} 30 | run: | 31 | cd "${{ github.action_path }}/../../scripts" 32 | bash install-releaser.sh 33 | 34 | - id: publish-changelog 35 | shell: ${{ inputs.shell }} 36 | run: | 37 | export GITHUB_ACCESS_TOKEN=${{ inputs.token }} 38 | export GITHUB_ACTOR=${{ github.triggering_actor }} 39 | export RH_REPOSITORY=${{ inputs.target }} 40 | if [ ! -z ${{ inputs.branch }} ]; then 41 | export RH_BRANCH=${{ inputs.branch }} 42 | fi 43 | export RH_DRY_RUN=${{ inputs.dry_run }} 44 | export RH_ADMIN_CHECK=false 45 | 46 | python -m jupyter_releaser.actions.publish_changelog 47 | 48 | - shell: ${{ inputs.shell }} 49 | run: | 50 | echo "## Next Step" >> $GITHUB_STEP_SUMMARY 51 | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" >> $GITHUB_STEP_SUMMARY 52 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | actions: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/scripts/bump_tag.sh: -------------------------------------------------------------------------------- 1 | set -eux 2 | 3 | # Update the v1 tag for GitHub Actions consumers 4 | if [[ ${RH_DRY_RUN:=true} != 'true' ]]; then 5 | git tag -f -a v2 -m "Github Action release" 6 | git push origin -f --tags 7 | fi 8 | -------------------------------------------------------------------------------- /.github/scripts/install-releaser.sh: -------------------------------------------------------------------------------- 1 | set -eux 2 | # Install Jupyter Releaser if it is not already installed 3 | 4 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 5 | 6 | if ! command -v jupyter-releaser &> /dev/null 7 | then 8 | cd "${SCRIPT_DIR}/../.." 9 | pip install -e . 10 | fi 11 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["*"] 5 | pull_request: 6 | branches: ["*"] 7 | release: 8 | types: [published] 9 | schedule: 10 | - cron: "0 0 * * *" 11 | 12 | jobs: 13 | check_release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 18 | - name: Install Dependencies 19 | shell: bash 20 | run: | 21 | pip install -e . 22 | - name: Check Release 23 | uses: ./.github/actions/check-release 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /.github/workflows/generate-changelog.yml: -------------------------------------------------------------------------------- 1 | name: Generate Changelog 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | target: 6 | description: "Target Owner/Repo" 7 | required: true 8 | branch: 9 | description: "The branch or reference name to filter pull requests by" 10 | required: false 11 | convert_to_rst: 12 | description: "Whether to convert to RST" 13 | required: false 14 | type: boolean 15 | since: 16 | description: "Use PRs with activity since this date or git reference" 17 | required: false 18 | until: 19 | description: "Use PRs with activity until this date or git reference" 20 | required: false 21 | since_last_stable: 22 | description: "Use PRs with activity since the last stable git tag" 23 | required: false 24 | type: boolean 25 | jobs: 26 | generate_changelog: 27 | runs-on: ubuntu-latest 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | python-version: ["3.10"] 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 35 | - name: Install Dependencies 36 | shell: bash 37 | run: | 38 | sudo apt-get install pandoc 39 | pip install -e . 40 | pip install pandoc 41 | 42 | - name: Generate the Changelog 43 | env: 44 | GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | run: | 46 | export RH_BRANCH=${{ github.event.inputs.branch }} 47 | export RH_SINCE=${{ github.event.inputs.since }} 48 | export RH_REF=refs/heads/${RH_BRANCH} 49 | export RH_REPOSITORY=${{ github.event.inputs.target }} 50 | export RH_UNTIL=${{ github.event.inputs.until }} 51 | export RH_CONVERT_TO_RST=${{ github.event.inputs.convert_to_rst }} 52 | export RH_SINCE_LAST_STABLE=${{ github.event.inputs.since_last_stable }} 53 | python -m jupyter_releaser.actions.generate_changelog 54 | cat CHANGELOG_ENTRY.md 55 | 56 | - uses: actions/upload-artifact@v4 57 | with: 58 | name: changelog-${{ github.job }}-${{ strategy.job-index }} 59 | path: CHANGELOG_ENTRY.md 60 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | target: 6 | description: "The owner/repo GitHub target" 7 | required: false 8 | version_spec: 9 | description: "New Version Specifier" 10 | default: "next" 11 | required: false 12 | branch: 13 | description: "The branch to target" 14 | required: false 15 | post_version_spec: 16 | description: "Post Version Specifier" 17 | required: false 18 | silent: 19 | description: "Set a placeholder in the changelog and don't publish the release." 20 | required: false 21 | type: boolean 22 | since: 23 | description: "Use PRs with activity since this date or git reference" 24 | required: false 25 | since_last_stable: 26 | description: "Use PRs with activity since the last stable git tag" 27 | required: false 28 | type: boolean 29 | jobs: 30 | prep_release: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 35 | - name: Install Dependencies 36 | shell: bash 37 | run: | 38 | pip install -e . 39 | - name: Prep Release 40 | id: prep-release 41 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 42 | with: 43 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 44 | version_spec: ${{ github.event.inputs.version_spec }} 45 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 46 | target: ${{ github.event.inputs.target }} 47 | branch: ${{ github.event.inputs.branch }} 48 | silent: ${{ github.event.inputs.silent }} 49 | since: ${{ github.event.inputs.since }} 50 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 51 | 52 | - name: "** Next Step **" 53 | run: | 54 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 55 | -------------------------------------------------------------------------------- /.github/workflows/prep-self-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Self Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | silent: 16 | description: "Set a placeholder in the changelog and don't publish the release." 17 | required: false 18 | type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 34 | - name: Install Dependencies 35 | shell: bash 36 | run: | 37 | pip install -e . 38 | - name: Prep Release 39 | id: prep-release 40 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 41 | with: 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | version_spec: ${{ github.event.inputs.version_spec }} 44 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 45 | target: jupyter-server/jupyter_releaser 46 | branch: ${{ github.event.inputs.branch }} 47 | silent: ${{ github.event.inputs.silent }} 48 | since: ${{ github.event.inputs.since }} 49 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 50 | 51 | - name: "** Next Step **" 52 | run: | 53 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 54 | -------------------------------------------------------------------------------- /.github/workflows/publish-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Changelog" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | target: 6 | description: "The owner/repo GitHub target" 7 | required: false 8 | branch: 9 | description: "The branch to target" 10 | required: false 11 | 12 | jobs: 13 | publish_changelog: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | 20 | - name: Install Dependencies 21 | shell: bash 22 | run: | 23 | pip install -e . 24 | 25 | - uses: actions/create-github-app-token@v1 26 | id: app-token 27 | with: 28 | app-id: ${{ vars.APP_ID }} 29 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 30 | 31 | - name: Publish changelog 32 | id: publish-changelog 33 | uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 34 | with: 35 | token: ${{ steps.app-token.outputs.token }} 36 | target: ${{ github.event.inputs.target }} 37 | branch: ${{ github.event.inputs.branch }} 38 | 39 | - name: "** Next Step **" 40 | run: | 41 | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | target: 6 | description: "The owner/repo GitHub target" 7 | required: false 8 | branch: 9 | description: "The target branch" 10 | required: false 11 | release_url: 12 | description: "The URL of the draft GitHub release" 13 | required: false 14 | steps_to_skip: 15 | description: "Comma separated list of steps to skip" 16 | required: false 17 | 18 | jobs: 19 | publish_release: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 24 | - name: Install Dependencies 25 | shell: bash 26 | run: | 27 | pip install -e . 28 | - name: Populate Release 29 | id: populate-release 30 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 31 | with: 32 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 33 | target: ${{ github.event.inputs.target }} 34 | branch: ${{ github.event.inputs.branch }} 35 | release_url: ${{ github.event.inputs.release_url }} 36 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 37 | 38 | - name: Finalize Release 39 | id: finalize-release 40 | env: 41 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 42 | PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }} 43 | TWINE_USERNAME: __token__ 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 46 | with: 47 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 48 | target: ${{ github.event.inputs.target }} 49 | release_url: ${{ steps.populate-release.outputs.release_url }} 50 | 51 | - name: "** Next Step **" 52 | if: ${{ success() }} 53 | run: | 54 | echo "Verify the final release" 55 | echo ${{ steps.finalize-release.outputs.release_url }} 56 | 57 | - name: "** Failure Message **" 58 | if: ${{ failure() }} 59 | run: | 60 | echo "Failed to Publish the Draft Release Url:" 61 | echo ${{ steps.populate-release.outputs.release_url }} 62 | -------------------------------------------------------------------------------- /.github/workflows/publish-self-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Self Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 24 | - name: Install Dependencies 25 | shell: bash 26 | run: | 27 | pip install -e . 28 | - uses: actions/create-github-app-token@v1 29 | id: app-token 30 | with: 31 | app-id: ${{ vars.APP_ID }} 32 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 33 | - name: Populate Release 34 | id: populate-release 35 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 36 | with: 37 | token: ${{ steps.app-token.outputs.token }} 38 | target: jupyter-server/jupyter_releaser 39 | branch: ${{ github.event.inputs.branch }} 40 | release_url: ${{ github.event.inputs.release_url }} 41 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 42 | 43 | - name: Finalize Release 44 | id: finalize-release 45 | env: 46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 48 | with: 49 | token: ${{ steps.app-token.outputs.token }} 50 | target: ${{ github.event.inputs.target }} 51 | release_url: ${{ steps.populate-release.outputs.release_url }} 52 | 53 | - name: "** Next Step **" 54 | if: ${{ success() }} 55 | run: | 56 | echo "Verify the final release" 57 | echo ${{ steps.finalize-release.outputs.release_url }} 58 | 59 | - name: "** Failure Message **" 60 | if: ${{ failure() }} 61 | run: | 62 | echo "Failed to Publish the Draft Release Url:" 63 | echo ${{ steps.populate-release.outputs.release_url }} 64 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | defaults: 10 | run: 11 | shell: bash -eux {0} 12 | 13 | env: 14 | PYTHONDONTWRITEBYTECODE: 1 15 | 16 | jobs: 17 | lint: 18 | name: Test Lint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | - name: Run Linters 24 | run: | 25 | hatch run typing:test 26 | hatch run lint:build 27 | pipx run interrogate -v jupyter_releaser 28 | pipx run doc8 --max-line-length=200 29 | 30 | check_links: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 35 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 36 | 37 | test: 38 | runs-on: ${{ matrix.os }} 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | os: [ubuntu-latest, windows-latest, macos-latest] 43 | python-version: ["3.9", "3.13"] 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 47 | 48 | - name: Run the tests with coverage on Ubuntu 49 | if: ${{ matrix.os == 'ubuntu-latest' }} 50 | run: | 51 | hatch run cov:test -n auto --cov-fail-under 80 || hatch run test:test --lf 52 | 53 | - name: Run the tests on Windows and MacOS 54 | if: ${{ matrix.os != 'ubuntu-latest' }} 55 | run: hatch run cov:test -s -n auto || hatch run cov:test -s --lf 56 | 57 | - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 58 | 59 | coverage: 60 | runs-on: ubuntu-latest 61 | needs: 62 | - test 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 66 | 67 | generate_changelog: 68 | runs-on: ubuntu-latest 69 | timeout-minutes: 10 70 | steps: 71 | - uses: actions/checkout@v4 72 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 73 | - env: 74 | GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | RH_REPOSITORY: jupyter-server/jupyter_releaser 76 | RH_SINCE: v0.10.2 77 | RH_UNTIL: v0.10.3 78 | run: | 79 | set -eux 80 | pip install -e . 81 | python -m jupyter_releaser.actions.generate_changelog 82 | cat CHANGELOG_ENTRY.md 83 | # Check for version entry contents 84 | cat CHANGELOG_ENTRY.md | grep -q "#234" 85 | cat CHANGELOG_ENTRY.md | grep -q "compare/${RH_SINCE}...${RH_UNTIL}" 86 | # make sure it works with different settings 87 | export RH_SINCE= 88 | export RH_UNTIL= 89 | export RH_CONVERT_TO_RST=true 90 | sudo apt-get install pandoc 91 | pip install pypandoc 92 | python -m jupyter_releaser.actions.generate_changelog 93 | cat CHANGELOG_ENTRY.md 94 | 95 | test_minimum_versions: 96 | name: Test Minimum Versions 97 | timeout-minutes: 20 98 | runs-on: ubuntu-latest 99 | steps: 100 | - uses: actions/checkout@v4 101 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 102 | with: 103 | dependency_type: minimum 104 | - name: Run the unit tests 105 | run: | 106 | hatch run test:nowarn || hatch run test:nowarn --lf 107 | 108 | test_prereleases: 109 | name: Test Prereleases 110 | runs-on: ubuntu-latest 111 | timeout-minutes: 20 112 | steps: 113 | - uses: actions/checkout@v4 114 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 115 | with: 116 | dependency_type: pre 117 | python_version: "3.12" 118 | - name: Run the tests 119 | run: | 120 | hatch run test:nowarn || hatch run test:nowarn --lf 121 | 122 | docs: 123 | runs-on: ubuntu-latest 124 | timeout-minutes: 10 125 | steps: 126 | - uses: actions/checkout@v4 127 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 128 | - run: hatch run docs:build 129 | 130 | check_local_actions: 131 | runs-on: ubuntu-latest 132 | steps: 133 | - uses: actions/checkout@v4 134 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 135 | 136 | - name: prep-release 137 | uses: ./.github/actions/prep-release 138 | with: 139 | token: ${{ secrets.GITHUB_TOKEN }} 140 | dry_run: true 141 | 142 | - name: populate-release 143 | uses: ./.github/actions/populate-release 144 | with: 145 | token: ${{ secrets.GITHUB_TOKEN }} 146 | release_url: ${{ steps.prep-release.outputs.release_url }} 147 | dry_run: true 148 | 149 | - name: publish-release 150 | uses: ./.github/actions/finalize-release 151 | with: 152 | token: ${{ secrets.GITHUB_TOKEN }} 153 | release_url: ${{ steps.populate-release.outputs.release_url }} 154 | dry_run: true 155 | 156 | check: # This job does nothing and is only used for the branch protection 157 | if: always() 158 | needs: 159 | - check_links 160 | - coverage 161 | - docs 162 | - lint 163 | - check_local_actions 164 | - test_minimum_versions 165 | # disabled until we can use pypiserver 2.3.x 166 | # which supports Python 3.13, see 167 | # https://github.com/pypiserver/pypiserver/issues/630 168 | # - test_prereleases 169 | - generate_changelog 170 | runs-on: ubuntu-latest 171 | steps: 172 | - name: Decide whether the needed jobs succeeded or failed 173 | uses: re-actors/alls-green@release/v1 174 | with: 175 | jobs: ${{ toJSON(needs) }} 176 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Local git checkout 132 | .jupyter_releaser_checkout 133 | 134 | # macOS 135 | .DS_Store 136 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autoupdate_commit_msg: "chore: update pre-commit hooks" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-case-conflict 10 | - id: check-ast 11 | - id: check-docstring-first 12 | - id: check-executables-have-shebangs 13 | - id: check-added-large-files 14 | - id: check-case-conflict 15 | - id: check-merge-conflict 16 | - id: check-json 17 | - id: check-toml 18 | - id: check-yaml 19 | - id: debug-statements 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | 23 | - repo: https://github.com/python-jsonschema/check-jsonschema 24 | rev: 0.27.4 25 | hooks: 26 | - id: check-github-workflows 27 | 28 | - repo: https://github.com/executablebooks/mdformat 29 | rev: 0.7.17 30 | hooks: 31 | - id: mdformat 32 | additional_dependencies: 33 | [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] 34 | 35 | - repo: https://github.com/pre-commit/mirrors-prettier 36 | rev: "v4.0.0-alpha.8" 37 | hooks: 38 | - id: prettier 39 | types_or: [yaml, html, json] 40 | exclude: '.pre-commit-config.yaml' 41 | 42 | - repo: https://github.com/pre-commit/mirrors-mypy 43 | rev: "v1.8.0" 44 | hooks: 45 | - id: mypy 46 | files: "^jupyter_releaser" 47 | exclude: "tests" 48 | stages: [manual] 49 | args: ["--install-types", "--non-interactive"] 50 | additional_dependencies: 51 | ["toml", "requests", "ghapi", "packaging","pkginfo", "fastapi", "click", "github_activity", "mdformat"] 52 | 53 | - repo: https://github.com/adamchainz/blacken-docs 54 | rev: "1.16.0" 55 | hooks: 56 | - id: blacken-docs 57 | additional_dependencies: [black==23.7.0] 58 | 59 | - repo: https://github.com/codespell-project/codespell 60 | rev: "v2.2.6" 61 | hooks: 62 | - id: codespell 63 | args: ["-L", "sur,nd"] 64 | 65 | - repo: https://github.com/pre-commit/pygrep-hooks 66 | rev: "v1.10.0" 67 | hooks: 68 | - id: rst-backticks 69 | - id: rst-directive-colons 70 | - id: rst-inline-touching-normal 71 | 72 | - repo: https://github.com/astral-sh/ruff-pre-commit 73 | rev: v0.2.0 74 | hooks: 75 | - id: ruff 76 | args: ["--fix", "--show-fixes"] 77 | - id: ruff-format 78 | 79 | - repo: https://github.com/scientific-python/cookie 80 | rev: "2024.01.24" 81 | hooks: 82 | - id: sp-repo-review 83 | additional_dependencies: ["repo-review[cli]"] 84 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/source/conf.py 4 | python: 5 | install: 6 | # install itself with pip install . 7 | - method: pip 8 | path: . 9 | extra_requirements: 10 | - docs 11 | build: 12 | os: ubuntu-22.04 13 | tools: 14 | python: "3.11" 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Jupyter Releaser 2 | 3 | ## General Jupyter contributor guidelines 4 | 5 | If you're reading this section, you're probably interested in contributing to 6 | Jupyter. Welcome and thanks for your interest in contributing! 7 | 8 | Please take a look at the Contributor documentation, familiarize yourself with 9 | using the Jupyter Server, and introduce yourself on the mailing list and 10 | share what area of the project you are interested in working on. 11 | 12 | For general documentation about contributing to Jupyter projects, see the 13 | [Project Jupyter Contributor Documentation](https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html) 14 | 15 | ## Setting Up a Development Environment 16 | 17 | Use the following steps: 18 | 19 | ```bash 20 | python -m pip install --upgrade setuptools pip 21 | git clone https://github.com/jupyter-server/jupyter_releaser 22 | cd jupyter-releaser 23 | pip install -e .[test] 24 | ``` 25 | 26 | If you are using a system-wide Python installation and you only want to install the package for you, 27 | you can add `--user` to the install commands. 28 | 29 | Set up pre-commit hooks for automatic code formatting, etc. 30 | 31 | ```bash 32 | pre-commit install 33 | ``` 34 | 35 | You can also invoke the pre-commit hook manually at any time with 36 | 37 | ```bash 38 | pre-commit run --all-files --hook-stage manual 39 | ``` 40 | 41 | Once you have done this, you can launch the main branch of Jupyter Releaser 42 | from any directory in your system with:: 43 | 44 | ```bash 45 | jupyter-releaser --help 46 | ``` 47 | 48 | ## Running Tests 49 | 50 | To run the Python tests, use: 51 | 52 | ```bash 53 | hatch run test:test 54 | ``` 55 | 56 | ## Documentation 57 | 58 | Contributions can also take the form of fixes and improvements to the documentation. 59 | 60 | To build the docs, run: 61 | 62 | ```bash 63 | hatch run docs:build 64 | ``` 65 | 66 | To serve the docs: 67 | 68 | ```bash 69 | hatch run docs:serve 70 | ``` 71 | 72 | It is also possible to automatically watch the docs with the following command: 73 | 74 | ```bash 75 | hatch run docs:watch 76 | ``` 77 | 78 | Then open http://localhost:8000 in your browser to view the documentation. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Project Jupyter Contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | 30 | Tee File License 31 | ================ 32 | 33 | The tee.py file is from https://github.com/pycontribs/subprocess-tee/ 34 | which is licensed under the "MIT" license. See the tee.py file for details. 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jupyter Releaser 2 | 3 | [![Build Status](https://github.com/jupyter-server/jupyter_releaser/actions/workflows/test.yml/badge.svg?query=branch%3Amain++)](https://github.com/jupyter-server/jupyter_releaser/actions/workflows/test.yml/badge.svg?query=branch%3Amain++) 4 | [![Documentation Status](https://readthedocs.org/projects/jupyter-releaser/badge/?version=latest)](http://jupyter-releaser.readthedocs.io/en/latest/?badge=latest) 5 | 6 | **Jupyter Releaser** contains a set of helper scripts and GitHub Actions to aid in automated releases of Python and npm packages. 7 | 8 | ## Installation 9 | 10 | To install the latest release locally, make sure you have 11 | [pip installed](https://pip.readthedocs.io/en/stable/installing/) and run: 12 | 13 | ```bash 14 | pip install git+https://github.com/jupyter-server/jupyter_releaser 15 | ``` 16 | 17 | ## Library Usage 18 | 19 | ```bash 20 | jupyter-releaser --help 21 | jupyter-releaser build-python --help 22 | jupyter-releaser check-npm --help 23 | ``` 24 | 25 | ## Checklist for Adoption 26 | 27 | See the [adoption guides](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/index.html). 28 | 29 | ## Actions 30 | 31 | GitHub actions scripts are available to draft a changelog, draft a release, publish a release, and check a release. 32 | 33 | See the [action details documentation](https://jupyter-releaser.readthedocs.io/en/latest/reference/theory.html#action-details) for more information. 34 | 35 | The actions can be run on a [fork](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_releaser.html) of `jupyter_releaser` and target multiple 36 | repositories, or run as workflows on the [source repositories](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html), using 37 | shared credentials. 38 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | - Run through the "Draft Changelog", "Draft Release", and "Publish Release" workflows 4 | - Update the tag for GitHub Actions consumers, e.g. 5 | 6 | ```bash 7 | git fetch origin main 8 | git checkout main 9 | git tag -f v1 10 | git push -f origin --tags 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* Added to avoid logo being too squeezed */ 2 | .navbar-brand { 3 | height: 4rem !important; 4 | } 5 | -------------------------------------------------------------------------------- /docs/source/_static/images/logo/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/source/_static/images/logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 12 | > 13 | 14 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | # import os 12 | # import sys 13 | # sys.path.insert(0, os.path.abspath('.')) 14 | import importlib.metadata 15 | import os.path as osp 16 | 17 | HERE = osp.abspath(osp.dirname(__file__)) 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "Jupyter Releaser" 22 | copyright = "2024, Project Jupyter" 23 | author = "Project Jupyter" 24 | 25 | # The full version, including alpha/beta/rc tags. 26 | release = importlib.metadata.version("jupyter_releaser") 27 | # The short X.Y version. 28 | version = ".".join(release.split(".")[:2]) 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.intersphinx", 38 | "sphinx.ext.napoleon", 39 | "myst_parser", 40 | "sphinx_click", 41 | ] 42 | 43 | try: 44 | import enchant # noqa: F401 45 | 46 | extensions += ["sphinxcontrib.spelling"] 47 | except ImportError: 48 | pass 49 | 50 | myst_enable_extensions = ["html_image", "tasklist"] 51 | 52 | # Add any paths that contain templates here, relative to this directory. 53 | templates_path = ["_templates"] 54 | 55 | # List of patterns, relative to source directory, that match files and 56 | # directories to ignore when looking for source files. 57 | # This pattern also affects html_static_path and html_extra_path. 58 | # exclude_patterns = [] 59 | 60 | 61 | # -- Options for HTML output ------------------------------------------------- 62 | 63 | # The theme to use for HTML and HTML Help pages. See the documentation for 64 | # a list of builtin themes. 65 | # 66 | html_theme = "pydata_sphinx_theme" 67 | 68 | html_logo = "_static/images/logo/logo.svg" 69 | html_favicon = "_static/images/logo/favicon.svg" 70 | 71 | # Add any paths that contain custom static files (such as style sheets) here, 72 | # relative to this directory. They are copied after the builtin static files, 73 | # so a file named "default.css" will overwrite the builtin "default.css". 74 | html_static_path = ["_static"] 75 | 76 | # Add an Edit this Page button 77 | html_theme_options = { 78 | "icon_links": [ 79 | { 80 | "name": "GitHub", 81 | "url": "https://github.com/jupyter-server/jupyter_releaser", 82 | "icon": "fab fa-github-square", 83 | } 84 | ], 85 | "use_edit_page_button": True, 86 | "navigation_with_keys": False, 87 | } 88 | 89 | # Output for github to be used in links 90 | html_context = { 91 | "github_user": "jupyter-server", # Username 92 | "github_repo": "jupyter_releaser", # Repo name 93 | "github_version": "main", # Version 94 | "doc_path": "/docs/source/", # Path in the checkout to the docs root 95 | } 96 | 97 | 98 | def setup(app): 99 | app.add_css_file("custom.css") 100 | -------------------------------------------------------------------------------- /docs/source/faq/index.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## How to resume a failed release? 4 | 5 | If the workflow failed to publish the release, for example because PyPI or npm was down during the release process, we can try to re-run the failed "Publish Release" workflow. 6 | 7 | If the draft GitHub release was correctly created, re-run the workflow this time specifying `populate-release` as a step to skip. That way the assets already attached to the draft GitHub Release and the associated release commit will not be recreated, and the workflow will skip to the "Finalize Release" step directly. 8 | 9 | ## Failed to publish a package to `npm` 10 | 11 | The releaser may fail to publish a package to the `npm` in the following cases: 12 | 13 | - `npmjs.com` is down, or `npm` is encountering issues publishing new packages 14 | - the account publishing the package to npm is not part of the list of collaborators 15 | - the package you are trying to publish does not contain the correct publish config. If the package is meant to be public, add the following to `package.json`: 16 | 17 | ```json 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | ``` 22 | 23 | ## `check-python` step fails with Python monorepos 24 | 25 | If you develop multiple Python packages within the same repository (as a monorepo), and the Python packages depend on each other, for example: 26 | 27 | ``` 28 | packages 29 | ├── bar 30 | └── foo 31 | ``` 32 | 33 | And `bar` depends on `foo`, for example with `foo>=1.0.0`. You may see the following error during the `check-python` step: 34 | 35 | ``` 36 | ERROR: Could not find a version that satisfies the requirement foo>=1.0.0 (from bar) (from versions: 1.0.0b4, 1.0.0b5, 1.0.0b6, 1.0.0b8) 37 | ERROR: No matching distribution found for foo>=1.0.0 38 | ``` 39 | 40 | This issue is not fixed yet and is being tracked in [this issue](https://github.com/jupyter-server/jupyter_releaser/issues/499). 41 | 42 | As a workaround, you can skip the `check-python` step with the following releaser config: 43 | 44 | ```toml 45 | [tool.jupyter-releaser] 46 | skip = ["check-python"] 47 | ``` 48 | 49 | ## How to only publish to `npm`? 50 | 51 | If you would like to use the Jupyter Releaser to publish to `npm` only, you can configure the releaser to skip the `build-python` step: 52 | 53 | ```toml 54 | [tool.jupyter-releaser] 55 | skip = ["build-python"] 56 | ``` 57 | 58 | ## My changelog is out of sync 59 | 60 | Create a new manual PR to fix the PR and re-orient the changelog entry markers. 61 | 62 | ## PR is merged to the target branch in the middle of a "Draft Release" 63 | 64 | The release will fail to push commits because it will not be up to date. Delete the pushed tags and re-start with "Draft Changelog" to 65 | pick up the new PR. 66 | 67 | ## What happens if one of my steps is failing but I want to force a release? 68 | 69 | This could happen for example if you need to override PRs to include in the changelog. In that case you would pass "check-changelog" to the 70 | workflow's "steps_to_skip" input option. 71 | -------------------------------------------------------------------------------- /docs/source/get_started/generate_changelog.md: -------------------------------------------------------------------------------- 1 | # Generating a Manual Changelog Entry 2 | 3 | The "Generate Changelog" workflow is useful for repositories or branches that do not yet use Jupyter Releaser. 4 | 5 | You can generate a markdown or reStructuredText changelog entry and manually apply it to the repo. 6 | 7 | The workflow does not require any credentials. 8 | 9 | To run the workflow: 10 | 11 | - Go to the "Actions" tab on the source repository or your fork. 12 | 13 | - Click on "Generate Changelog" on the left navigation bar 14 | 15 | - Click the "Run workflow" dropdown button on the right and fill in the appropriate values 16 | 17 | ![Generate Changelog Workflow Dialog](../images/generate_changelog.png) 18 | 19 | - Write "true" for "Whether to convert to RST" if the target repo uses a reStructuredText changelog 20 | 21 | - The last two fields can be used to constrain the changelog entries to a set of tags or other references 22 | 23 | - When the job finishes, download and extract the generated changelog 24 | -------------------------------------------------------------------------------- /docs/source/get_started/index.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Tutorials. A hands-on introduction to Jupyter Releaser for maintainers. 4 | 5 | ```{toctree} 6 | :caption: 'Contents:' 7 | :maxdepth: 1 8 | 9 | making_release_from_repo 10 | making_release_from_releaser 11 | generate_changelog 12 | ``` 13 | -------------------------------------------------------------------------------- /docs/source/get_started/making_release_from_releaser.md: -------------------------------------------------------------------------------- 1 | # Making Your First Release from Jupyter Releaser 2 | 3 | This guide covers creating your first release on a repository that 4 | already uses Jupyter Releaser. 5 | 6 | ## Prerequisites 7 | 8 | - Write access to the target repository 9 | - Publish access to PYPI and/or npm assets associated with the repo 10 | 11 | ## Set up 12 | 13 | - Fork `jupyter_releaser` 14 | 15 | - Generate a [GitHub Access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) with access to target GitHub repo to run GitHub Actions 16 | 17 | - Add the token as `ADMIN_GITHUB_TOKEN` in the [repository secrets](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository) of your fork. The token must have `repo` and `workflow` scopes. 18 | 19 | - Set up PyPI: 20 | 21 |
Using PyPI token (legacy way) 22 | 23 | - If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons. 24 | 25 | - You can store the token as `PYPI_TOKEN` in your fork's `Secrets`. 26 | 27 | - Advanced usage: if you are releasing multiple repos, you can create a secret named `PYPI_TOKEN_MAP` instead of `PYPI_TOKEN` that is formatted as follows: 28 | 29 | ```text 30 | owner1/repo1,token1 31 | owner2/repo2,token2 32 | ``` 33 | 34 | If you have multiple Python packages in the same repository, you can point to them as follows: 35 | 36 | ```text 37 | owner1/repo1/path/to/package1,token1 38 | owner1/repo1/path/to/package2,token2 39 | ``` 40 | 41 |
42 | 43 |
Using PyPI trusted publisher (modern way) 44 | 45 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 46 | - if you use the example workflows, the _workflow name_ is `publish-release.yml` (or `full-release.yml`) and the 47 | _environment_ should be left blank. 48 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 49 | 50 |
51 | 52 | - If the repo generates npm release(s), add access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens), saved as `NPM_TOKEN` in "Secrets". 53 | 54 | > If you want to set _provenance_ on your package, you need to ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions)). 55 | 56 | ## Prep Release 57 | 58 | - Go to the "Actions" tab in your fork of `jupyter_releaser` 59 | 60 | - Select the "Step 1: Prep Release" workflow on the left 61 | 62 | - Click on the "Run workflow" dropdown button on the right 63 | 64 | - Fill in the appropriate parameters 65 | 66 | ![Prep Release Workflow Dialog](../images/prep_release.png) 67 | 68 | - The "New Version Spec" will usually be the full version (e.g. 0.7.1). Repos using `tbump` can also use the "next" or "patch" 69 | option, which will bump the micro version (or the build version in the case of a prerelease). The "minor" option allows projects using "tbump" to bump 70 | to the next minor version directly. Note: The "next" and "patch" options 71 | are not available when using dev versions, you must use explicit versions 72 | instead. 73 | 74 | - Use the "since" field to select PRs prior to the latest tag to include in the release 75 | 76 | - Check "Use PRs with activity since the last stable git tag" if you would like to include PRs since the last non-prerelease version tagged on the target repository and branch. 77 | 78 | - Check "Set a placeholder in the changelog and don't publish the release" if 79 | you want to carry a silent release (e.g. in case of a security release). 80 | That option will change the default behavior by keeping the version 81 | changelog only in the GitHub release and keeping it private (aka in _Draft_ 82 | state). The changelog file will only contains a placeholder to be replaced 83 | by the release body once the maintainers have chosen to publish the release. 84 | 85 | - The additional "Post Version Spec" field should be used if your repo uses a dev version (e.g. 0.7.0.dev0) 86 | 87 | - The workflow will use the GitHub API to find the relevant pull requests and make an appropriate changelog entry. 88 | 89 | - The workflow will create a draft GitHub release to the target 90 | repository and branch, with the draft changelog contents. 91 | 92 | ![Prep Release Changelog Workflow Next Step](../images/prep_release_next_step.png) 93 | 94 | ## Review Changelog 95 | 96 | - Go to the draft GitHub Release created by the "Prep Release" workflow 97 | 98 | ![Draft GitHub Release](../images/draft_github_release.png) 99 | 100 | - Review the contents, fixing typos or otherwise editing as necessary. 101 | 102 | - If there is a section called "Other Merged PRs", it means those PRs did not have one of the appropriate labels. If desired, you can go label those PRs and then re-run the workflow, or move the entries manually to the desired section. The appropriate labels are: bug, maintenance, enhancement, feature, and documentation. 103 | 104 | ## Publish Release 105 | 106 | - Return to your fork of `jupyter_releaser` 107 | 108 | - Click on the "Actions" tab 109 | 110 | - Select the "Publish Release" workflow on the left 111 | 112 | - Click on the "Run workflow" button on the right 113 | 114 | - Fill in the target repository 115 | 116 | - (Optional) Fill in draft GitHub Release URL given by the Changelog PR. 117 | If you leave it blank it will use the most recent draft GitHub release. 118 | 119 | ![Publish Release Workflow Dialog](../images/publish_release.png) 120 | 121 | - The workflow will finish the GitHub release and publish assets to the appropriate registries. 122 | 123 | - If the workflow is not targeting the default branch, it will also generate a forward-port pull request for the changelog entry to the default branch. 124 | 125 | - When the workflow finishes it will print a link to the GitHub release and the forward-port PR (if appropriate) in the "\*\* Next Step \*\*" output. 126 | 127 | ![Publish Release Workflow Next Step](../images/publish_release_next_step.png) 128 | 129 | - **Note** If the publish portion fails you can attempt to publish the draft GitHub release given by the URL in the "\*\* Failure Message \*\*" using the "Publish Release" workflow again. It will skip past the asset creation phase 130 | and move into asset publish. 131 | 132 | - **Note** GitHub Actions caches the secrets used on a given workflow run. So if you run into an auth issue, you'll 133 | need to run a new workflow instead of re-running the existing workflow. 134 | 135 | - Review and merge the forward-port PR if applicable 136 | 137 | - Announce the release on appropriate channels 138 | -------------------------------------------------------------------------------- /docs/source/get_started/making_release_from_repo.md: -------------------------------------------------------------------------------- 1 | # Making Your First Release from Repo 2 | 3 | This guide covers creating your first release on a repository that 4 | already uses Jupyter Releaser using workflows on its own repository. 5 | 6 | ## Prerequisites 7 | 8 | - Admin write access to the target repository 9 | - Completed the [Checklist for Adoption](../how_to_guides/convert_repo_from_repo.md) 10 | 11 | ## Prep Release 12 | 13 | - Go to the "Actions" tab in the repository. 14 | 15 | - Select the "Step 1: Prep Release" workflow on the left 16 | 17 | - Click on the "Run workflow" dropdown button on the right 18 | 19 | - Fill in the appropriate parameters 20 | 21 | ![Prep Release Workflow Dialog](../images/prep_release_repo.png) 22 | 23 | - The "New Version Spec" will usually be the full version (e.g. 0.7.1). 24 | Repos using `hatch` can also use [segments](https://hatch.pypa.io/latest/version/#supported-segments) such as _patch_, _minor_, _alpha_,... . 25 | 26 | - Use the "since" field to select PRs prior to the latest tag to include in the release 27 | 28 | - Check "Use PRs with activity since the last stable git tag" if you would like to include PRs since the last non-prerelease version tagged on the target repository and branch. 29 | 30 | - The additional "Post Version Spec" field should be used if your repo uses a dev version (e.g. 0.7.0.dev0) 31 | 32 | - Check "Set a placeholder in the changelog and don't publish the release" if 33 | you want to carry a silent release (e.g. in case of a security release). 34 | That option will change the default behavior by keeping the version 35 | changelog only in the GitHub release and keeping it private (aka in _Draft_ 36 | state). The changelog file will only contains a placeholder to be replaced 37 | by the release body once the maintainers have chosen to publish the release. 38 | 39 | - The workflow will use the GitHub API to find the relevant pull requests and make an appropriate changelog entry. 40 | 41 | - The workflow will create a draft GitHub release to the target 42 | repository and branch, with the draft changelog contents. 43 | 44 | ![Prep Release Changelog Workflow Next Step](../images/prep_release_next_step.png) 45 | 46 | ## Review Changelog 47 | 48 | - Go to the draft GitHub Release created by the "Prep Release" workflow 49 | 50 | ![Draft GitHub Release](../images/draft_github_release.png) 51 | 52 | - Review the contents, fixing typos or otherwise editing as necessary. 53 | 54 | - If there is a section called "Other Merged PRs", it means those PRs did not have one of the appropriate labels. If desired, you can go label those PRs and then re-run the workflow, or move the entries manually to the desired section. The appropriate labels are: bug, maintenance, enhancement, feature, and documentation. 55 | 56 | ## Publish Release 57 | 58 | - Return to "Actions" tab for the repository 59 | 60 | - Select the "Publish Release" workflow on the left 61 | 62 | - Click on the "Run workflow" button on the right 63 | 64 | - (Optional) Fill in draft GitHub Release URL given by the Changelog PR. 65 | If you leave it blank it will use the most recent draft GitHub release. 66 | 67 | ![Publish Release Workflow Dialog](../images/publish_release_repo.png) 68 | 69 | - The workflow will finish the GitHub release and publish assets to the appropriate registries. 70 | 71 | - If the workflow is not targeting the default branch, it will also generate a forward-port pull request for the changelog entry to the default branch. 72 | 73 | - When the workflow finishes it will print a link to the GitHub release and the forward-port PR (if appropriate) in the "\*\* Next Step \*\*" output. 74 | 75 | ![Publish Release Workflow Next Step](../images/publish_release_next_step.png) 76 | 77 | - **Note** If the publish portion fails you can attempt to publish the draft GitHub release given by the URL in the "\*\* Failure Message \*\*" using the "Publish Release" workflow again. It will skip past the asset creation phase 78 | and move into asset publish. 79 | 80 | - **Note** GitHub Actions caches the secrets used on a given workflow run. So if you run into an auth issue, you'll 81 | need to run a new workflow instead of re-running the existing workflow. 82 | 83 | - Review and merge the forward-port PR if applicable 84 | 85 | - Announce the release on appropriate channels 86 | -------------------------------------------------------------------------------- /docs/source/how_to_guides/convert_repo_from_releaser.md: -------------------------------------------------------------------------------- 1 | # Convert a Repo to Use Releaser from Releaser 2 | 3 | Follow the steps below to convert a repository to use Jupyter Releaser for releases, where maintainers make releases from a fork of Jupyter Releaser. 4 | 5 | ## Prerequisites 6 | 7 | See checklist below for details: 8 | 9 | - Markdown changelog 10 | - Bump version configuration (if using Python), for example [tbump](https://github.com/dmerejkowsky/tbump) 11 | - [Access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) with access to target GitHub repo to run GitHub Actions. 12 | - Access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github) 13 | - If needed, access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens). 14 | 15 | ## Checklist for Adoption 16 | 17 | A. Prep the `jupyter_releaser` fork: 18 | 19 | - [ ] Clone this repository onto your GitHub user account. 20 | 21 | - [ ] Add a GitHub [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) with access to target GitHub repo to run 22 | GitHub Actions, saved as `ADMIN_GITHUB_TOKEN` in the 23 | [repository secrets](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository). 24 | The token will need "public_repo", and "repo:status" permissions. 25 | 26 | - [ ] Set up PyPI: 27 | 28 |
Using PyPI token (legacy way) 29 | 30 | - Add access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github) stored as `PYPI_TOKEN`. 31 | _Note_ For security reasons, it is recommended that you scope the access 32 | to a single repository, and use a variable called `PYPI_TOKEN_MAP` that is formatted as follows: 33 | 34 | ```text 35 | owner1/repo1,token1 36 | owner2/repo2,token2 37 | ``` 38 | 39 | If you have multiple Python packages in the same repository, you can point to them as follows: 40 | 41 | ```text 42 | owner1/repo1/path/to/package1,token1 43 | owner1/repo1/path/to/package2,token2 44 | ``` 45 | 46 |
47 | 48 |
Using PyPI trusted publisher (modern way) 49 | 50 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 51 | - if you use the example workflows, the _workflow name_ is `publish-release.yml` (or `full-release.yml`) and the 52 | _environment_ should be left blank. 53 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 54 | 55 |
56 | 57 | - [ ] If needed, add access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens), saved as `NPM_TOKEN`. 58 | 59 | > If you want to set _provenance_ on your package, you need to ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions)). 60 | 61 | B. Prep target repository: 62 | 63 | - [ ] Switch to Markdown Changelog 64 | - We recommend [MyST](https://myst-parser.readthedocs.io/en/latest/?badge=latest), especially if some of your docs are in reStructuredText. 65 | - Can use `pandoc -s changelog.rst -o changelog.md` and some hand edits as needed. 66 | - Note that [directives](https://myst-parser.readthedocs.io/en/latest/using/syntax.html#syntax-directives) can still be used 67 | - [ ] Add HTML start and end comment markers to Changelog file - see example in [CHANGELOG.md](https://github.com/jupyter-server/jupyter_releaser/blob/main/CHANGELOG.md) (view in raw mode) 68 | - [ ] We recommend using [hatch](https://hatch.pypa.io/latest/) for your 69 | build system and for version handling. 70 | - If previously providing `version_info` like `version_info = (1, 7, 0, '.dev', '0')`, use a pattern like the one below in your version file: 71 | 72 | ```python 73 | import re 74 | from typing import List 75 | 76 | # Version string must appear intact for hatch versioning 77 | __version__ = "6.16.0" 78 | 79 | # Build up version_info tuple for backwards compatibility 80 | pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" 81 | match = re.match(pattern, __version__) 82 | assert match is not None 83 | parts: List[object] = [int(match[part]) for part in ["major", "minor", "patch"]] 84 | if match["rest"]: 85 | parts.append(match["rest"]) 86 | version_info = tuple(parts) 87 | ``` 88 | 89 | - If you need to keep node and python versions in sync, use [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version). See [nbformat](https://github.com/jupyter/nbformat/blob/main/pyproject.toml) for example. 90 | 91 | - [ ] Add a GitHub Actions CI step to run the `check_release` action. For example: 92 | 93 | ```yaml 94 | - name: Check Release 95 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 96 | with: 97 | token: ${{ secrets.GITHUB_TOKEN }} 98 | ``` 99 | 100 | - This should be run on `push` and `pull` request events. You can copy 101 | the `check-release.yml` from this repo as an example. 102 | 103 | - [ ] If you would like the release assets to be uploaded as artifacts, add the following step after the `check_release` action: 104 | 105 | ```yaml 106 | - name: Upload Distributions 107 | uses: actions/upload-artifact@v2 108 | with: 109 | name: jupyter-releaser-dist-${{ github.run_number }} 110 | path: .jupyter_releaser_checkout/dist 111 | ``` 112 | 113 | - [ ] Add a workflow that uses the [`enforce-label`](https://github.com/jupyterlab/maintainer-tools#enforce-labels) action from `jupyterlab/maintainer-tools` to ensure that all PRs have on of the triage labels used to 114 | categorize the changelog. 115 | 116 | - [ ] Update or add `RELEASE.md` that describes the onboarding and release process, e.g. [jupyter_server](https://github.com/jupyter-server/jupyter_server/blob/main/RELEASE.md). 117 | 118 | ## Release Workflow 119 | 120 | - [ ] Set up a fork of `jupyter-releaser` if you have not yet done so. 121 | - [ ] Run through the release process, targeting this repo and the appropriate branch 122 | - [ ] Optionally add configuration to the target repository if non-standard options or hooks are needed. 123 | - [ ] If desired, add `check_release` job, changelog, and `tbump` support to other active release branches 124 | - [ ] Try out the `Draft Changelog` and `Draft Release` process against a fork of the target repo first so you don't accidentally push tags and GitHub releases to the source repository. 125 | - [ ] Try the `Publish Release` process using a prerelease version before publishing a final version. 126 | 127 | ## Backport Branches 128 | 129 | - Create backport branches the usual way, e.g. `git checkout -b 3.0.x v3.0.1; git push origin 3.0.x` 130 | - When running the `Publish Release` Workflow, an automatic PR is generated for the default branch 131 | in the target repo, positioned in the appropriate place in the changelog. 132 | -------------------------------------------------------------------------------- /docs/source/how_to_guides/convert_repo_from_repo.md: -------------------------------------------------------------------------------- 1 | # Convert a Repo to Use Releaser from Repo 2 | 3 | Follow the steps below to convert a repository to use Jupyter Releaser for releases, where maintainers make releases from the repository itself. 4 | 5 | ## Prerequisites 6 | 7 | See checklist below for details: 8 | 9 | - Markdown changelog 10 | - Bump version configuration (if using Python), for example [hatch](https://hatch.pypa.io/latest/) 11 | - [Add a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) to your PyPI project 12 | - If needed, access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens). 13 | 14 | ## Checklist for Adoption 15 | 16 | - [ ] Set up a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps#github-apps-that-act-on-their-own-behalf) on your organization (or personal account for a personal project). 17 | 18 | - Disable the web hook 19 | - Enable Repository permissions > Contents > Read and write 20 | - Select "Only on this account" 21 | - Click "Create GitHub App" 22 | - Browse to the App Settings 23 | - Select "Install App" and install on all repositories 24 | - Under "General" click "Generate a private key" 25 | - Store the `APP_ID` and the private key in a secure location (Jupyter Vault if using a Jupyter Org) 26 | 27 | - [ ] Create a "release" [environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) on your repository and add an `APP_ID` Environment Variable and `APP_PRIVATE_KEY` secret. 28 | The environment should be enabled for ["Protected branches only"](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-branches-and-tags). 29 | 30 | - [ ] Configure [Rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) for the repository 31 | 32 | - Set up branch protection (with default rules) on publication branches 33 | - Remove global tag protection. 34 | - Add a branch Ruleset for all branches 35 | - Allow the GitHub App to bypass protections 36 | - Set up Pull Request and Required Checks 37 | - Add a tags Ruleset for all tags 38 | - Allow the GitHub App to bypass protections 39 | 40 | - [ ] Copy `prep-release.yml` and `publish-release.yml` (or only `full-release.yml`) from the 41 | [example-workflows](https://github.com/jupyter-server/jupyter_releaser/tree/main/example-workflows) folder in this repository. 42 | 43 | - [ ] Set up PyPI: 44 | 45 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 46 | - if you use the example workflows, the _workflow name_ is `publish-release.yml` (or `full-release.yml`) and the 47 | _environment_ should be `release` (the name of the GitHub environment). 48 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 49 | 50 | - [ ] If needed, add access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens), saved as `NPM_TOKEN`. Again this should 51 | be created using a machine account that only has publish access. 52 | 53 | - [ ] Ensure that only trusted users with 2FA have admin access to the repository, since they will be able to trigger releases. 54 | 55 | - [ ] Switch to Markdown Changelog 56 | 57 | - We recommend [MyST](https://myst-parser.readthedocs.io/en/latest/?badge=latest), especially if some of your docs are in reStructuredText. 58 | - Can use `pandoc -s changelog.rst -o changelog.md` and some hand edits as needed. 59 | - Note that [directives](https://myst-parser.readthedocs.io/en/latest/using/syntax.html#syntax-directives) can still be used 60 | 61 | - [ ] Add HTML start and end comment markers to Changelog file 62 | 63 | - see example in [CHANGELOG.md](https://github.com/jupyter-server/jupyter_releaser/blob/main/CHANGELOG.md) (view in raw mode) 64 | 65 | ```md 66 | # Changelog 67 | 68 | 69 | 70 | 71 | ``` 72 | 73 | - [ ] We recommend using [hatch](https://hatch.pypa.io/latest/) for your 74 | build system and for version handling. 75 | - If previously providing `version_info` like `version_info = (1, 7, 0, '.dev', '0')`, 76 | use a pattern like the one below in your version file: 77 | 78 | ```python 79 | import re 80 | from typing import List 81 | 82 | # Version string must appear intact for hatch versioning 83 | __version__ = "6.16.0" 84 | 85 | # Build up version_info tuple for backwards compatibility 86 | pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" 87 | match = re.match(pattern, __version__) 88 | assert match is not None 89 | parts: List[object] = [int(match[part]) for part in ["major", "minor", "patch"]] 90 | if match["rest"]: 91 | parts.append(match["rest"]) 92 | version_info = tuple(parts) 93 | ``` 94 | 95 | - If you need to keep node and python versions in sync, use [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version). 96 | 97 | - See [nbformat](https://github.com/jupyter/nbformat/blob/main/pyproject.toml) for example. 98 | 99 | - [ ] Add a GitHub Actions CI step to run the `check_release` action. For example: 100 | 101 | - This should be run on `push` and `pull` request events. You can copy 102 | the `check-release.yml` from this repo as an example. 103 | 104 | ```yaml 105 | - name: Check Release 106 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 107 | with: 108 | token: ${{ secrets.GITHUB_TOKEN }} 109 | ``` 110 | 111 | - [ ] If you would like the release assets to be uploaded as artifacts, add the following step after the `check_release` action: 112 | 113 | ```yaml 114 | - name: Upload Distributions 115 | uses: actions/upload-artifact@v4 116 | with: 117 | name: dist-${{ github.run_number }} 118 | path: .jupyter_releaser_checkout/dist 119 | ``` 120 | 121 | - [ ] Add a workflow that uses the [`enforce-label`](https://github.com/jupyterlab/maintainer-tools#enforce-labels) action 122 | from `jupyterlab/maintainer-tools` to ensure that all PRs have on of the triage labels used to categorize the changelog. 123 | 124 | ```yaml 125 | name: Enforce PR label 126 | 127 | on: 128 | pull_request: 129 | types: [labeled, unlabeled, opened, edited, synchronize] 130 | 131 | jobs: 132 | enforce-label: 133 | runs-on: ubuntu-latest 134 | permissions: 135 | pull-requests: write 136 | steps: 137 | - name: enforce-triage-label 138 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 139 | ``` 140 | 141 | - [ ] Update or add `RELEASE.md` that describes the onboarding and release process, e.g. [jupyter_server](https://github.com/jupyter-server/jupyter_server/blob/main/RELEASE.md). 142 | 143 | - [ ] Optionally add configuration to the repository if non-standard options or hooks are needed. 144 | 145 | - [ ] If desired, add `check_release` job, changelog, and `hatch` support to other active release branches 146 | 147 | ## Initial Release Workflow 148 | 149 | - [ ] Try out the `Prep Release` and `Publish Release` process against a fork of the target repo first so you don't accidentally push tags and GitHub releases to the source repository. Set the `TWINE_REPOSITORY_URL` environment variable to `https://test.pypi.org/legacy/` in the "Finalize Release" action part of the workflow 150 | 151 | - [ ] Try the `Publish Release` process using a prerelease version on the main 152 | repository before publishing a final version. 153 | 154 | ## Backport Branches 155 | 156 | - Create backport branches the usual way, e.g. `git checkout -b 3.0.x v3.0.1; git push origin 3.0.x` 157 | - When running the `Publish Release` Workflow, an automatic PR is generated for the default branch 158 | in the target repo, positioned in the appropriate place in the changelog. 159 | -------------------------------------------------------------------------------- /docs/source/how_to_guides/index.md: -------------------------------------------------------------------------------- 1 | # How-to Guides 2 | 3 | Step-by-step guides. Covers key tasks and operations and common problems 4 | 5 | ```{toctree} 6 | :caption: 'Contents:' 7 | :maxdepth: 1 8 | 9 | convert_repo_from_releaser 10 | convert_repo_from_repo 11 | write_config 12 | maintain_fork 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/source/how_to_guides/maintain_fork.md: -------------------------------------------------------------------------------- 1 | # Maintain a Releaser Fork 2 | 3 | ## How to keep fork of Jupyter Releaser up to date 4 | 5 | - The manual workflow files target the `@v2` actions in the source repository, which means that as long as 6 | the workflow files themselves are up to date, you will always be running the most up to date actions. 7 | 8 | - Make sure your workflow is up to date by checking the "Fetch Upstream" dropdown on the main page of your fork. 9 | 10 | ![Fetch Upstream Dropdown](../images/fork_fetch.png) 11 | -------------------------------------------------------------------------------- /docs/source/how_to_guides/write_config.md: -------------------------------------------------------------------------------- 1 | # Write Releaser Config 2 | 3 | ## Command Options and Environment Variables 4 | 5 | All of the commands support CLI and Environment Variable Overrides. 6 | The environment variables are defined by the `envvar` parameters in the 7 | command options in `cli.py`. The environment variables unique to 8 | `jupyter-releaser` are prefixed with `RH_`. A list of all env variables can be seen 9 | by running `jupyter-releaser list-envvars`. 10 | 11 | ## Default Values, Options, Skip, and Hooks 12 | 13 | The default values can also be overridden using a config file. 14 | 15 | Options can be overridden using the `options` section. 16 | 17 | You can skip one or more commands using a `skip` section, which is a list of 18 | commands to skip. 19 | 20 | You can also define hooks to run before and after 21 | commands in a `hooks` section. Hooks can be a shell command to run or 22 | a list of shell commands, and are specified to run `before-` or `after-` 23 | a command. 24 | Note: the only unusable hook names are `before-prep-git` and `before-extract-release`, since a checkout of the target repository is not yet available at that point. 25 | 26 | ## Configuration File Priority 27 | 28 | This is where `jupyter-releaser` looks for configuration (first one found is used): 29 | 30 | - `.jupyter-releaser.toml` 31 | - `pyproject.toml` (in the tool.jupyter-releaser section) 32 | - `package.json` (in the jupyter-releaser property) 33 | 34 | Example `.jupyter-releaser.toml`: 35 | 36 | ```toml 37 | [options] 38 | dist_dir = "mydist" 39 | 40 | [hooks] 41 | before-tag-version = "npm run pre:tag:script" 42 | ``` 43 | 44 | Example `pyproject.toml` section: 45 | 46 | ```toml 47 | [tool.jupyter-releaser.options] 48 | dist_dir = "mydist" 49 | 50 | [tool.jupyter-releaser] 51 | 52 | [tool.jupyter-releaser.hooks] 53 | after-build-python = ["python scripts/cleanup.py", "python scripts/send_email.py"] 54 | ``` 55 | 56 | Example `package.json`: 57 | 58 | ```json 59 | { 60 | "name": "my-package", 61 | "jupyter-releaser": { 62 | "options": { 63 | "dist_dir": "mydist" 64 | }, 65 | "skip": ["check-npm"], 66 | "hooks": { 67 | "before-publish-dist": "npm run pre:publish:dist" 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | ## Automatic Dev Versions 74 | 75 | If you'd like to use dev versions for your repository between builds, 76 | use `dev` as the `post-version-spec` setting, e.g. 77 | 78 | ```toml 79 | [tool.jupyter-releaser.options] 80 | post-version-spec = "dev" 81 | ``` 82 | 83 | This will bump it to the next minor release with a `.dev0` suffix. 84 | 85 | ## Ensuring Python Resource Files 86 | 87 | If you want to ensure that resource files are included in your installed Python 88 | package 89 | (from an sdist or a wheel), include configuration like the following: 90 | 91 | ```toml 92 | [tool.jupyter-releaser.options] 93 | pydist_resource_paths = ["my-package/img1.png", "my-package/foo/bar.json"] 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/source/images/draft_github_release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/draft_github_release.png -------------------------------------------------------------------------------- /docs/source/images/final_github_release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/final_github_release.png -------------------------------------------------------------------------------- /docs/source/images/fork_fetch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/fork_fetch.png -------------------------------------------------------------------------------- /docs/source/images/generate_changelog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/generate_changelog.png -------------------------------------------------------------------------------- /docs/source/images/prep_release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/prep_release.png -------------------------------------------------------------------------------- /docs/source/images/prep_release_next_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/prep_release_next_step.png -------------------------------------------------------------------------------- /docs/source/images/prep_release_repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/prep_release_repo.png -------------------------------------------------------------------------------- /docs/source/images/publish_release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/publish_release.png -------------------------------------------------------------------------------- /docs/source/images/publish_release_next_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/publish_release_next_step.png -------------------------------------------------------------------------------- /docs/source/images/publish_release_repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/docs/source/images/publish_release_repo.png -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Jupyter Releaser 2 | 3 | **Jupyter Releaser** contains a set of helper scripts and GitHub Actions to aid in automated releases of Python and npm packages. 4 | 5 | ## Documentation 6 | 7 | ```{toctree} 8 | :maxdepth: 2 9 | 10 | get_started/index 11 | how_to_guides/index 12 | reference/index 13 | faq/index 14 | ``` 15 | 16 | ```{toctree} 17 | :maxdepth: 1 18 | 19 | reference/changelog 20 | ``` 21 | 22 | ### Blog post 23 | 24 | For a high level overview of the Jupyter Releaser, check out [this post](https://blog.jupyter.org/automate-your-releases-with-the-jupyter-releaser-701e7b9841e6) on the Jupyter blog (published on October 29th, 2024). 25 | 26 | ## Indices and tables 27 | 28 | - {ref}`genindex` 29 | - {ref}`modindex` 30 | - {ref}`search` 31 | -------------------------------------------------------------------------------- /docs/source/reference/api_docs.md: -------------------------------------------------------------------------------- 1 | # API Docs 2 | 3 | ## Library Functions 4 | 5 | ```{eval-rst} 6 | .. automodule:: jupyter_releaser.lib 7 | :members: 8 | ``` 9 | 10 | ## Python Utility Functions 11 | 12 | ```{eval-rst} 13 | .. automodule:: jupyter_releaser.python 14 | :members: 15 | 16 | ``` 17 | 18 | ## NPM Utility Functions 19 | 20 | ```{eval-rst} 21 | .. automodule:: jupyter_releaser.npm 22 | :members: 23 | 24 | ``` 25 | 26 | ## Changelog Utility Functions 27 | 28 | ```{eval-rst} 29 | .. automodule:: jupyter_releaser.changelog 30 | :members: 31 | 32 | ``` 33 | 34 | ## Global Utility Functions 35 | 36 | ```{eval-rst} 37 | .. automodule:: jupyter_releaser.util 38 | :members: 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/source/reference/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../../CHANGELOG.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/source/reference/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Coming Soon! 4 | -------------------------------------------------------------------------------- /docs/source/reference/index.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | Technical reference. Covers tools, components, commands, and resources. 4 | 5 | ```{toctree} 6 | :maxdepth: 1 7 | :caption: Contents: 8 | 9 | releaser_cli 10 | api_docs 11 | configuration 12 | theory 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/source/reference/releaser_cli.md: -------------------------------------------------------------------------------- 1 | # Releaser CLI 2 | 3 | ```{click} jupyter_releaser.cli:main 4 | :prog: jupyter-releaser 5 | :nested: full 6 | ``` 7 | -------------------------------------------------------------------------------- /docs/source/reference/theory.md: -------------------------------------------------------------------------------- 1 | # Theory 2 | 3 | ## Motivation 4 | 5 | This project should help maintainers reduce toil and save time in the release process by enforcing best practices to: 6 | 7 | - Automate a changelog for every release 8 | - Verify the install and import of dist asset(s) 9 | - Commit a message with hashes of dist file(s) 10 | - Annotate the git tag in standard format 11 | - Create a GitHub release with changelog entry 12 | - Forward port changelog entries into default branch 13 | - Dry run publish on CI 14 | - Revert to Dev version after release (optional) 15 | 16 | ## Security 17 | 18 | We strive to use the most secure release practices possible, reflected in the `Checklist for Adoption` 19 | and the example workflows. 20 | This includes using PyPI Trusted Publishing, using GitHub Environments, encouraging the use of Rulesets and GitHub Apps with limited bypass capability, and provenance data for npm. 21 | In addition, there is an automatic check for whether the user who triggered the action is an admin. 22 | 23 | ## Action Details 24 | 25 | Detailed workflows are available to draft a changelog, draft a release, publish a release, and check a release. 26 | 27 | ### Prep Release Action 28 | 29 | - Inputs are the target repo, branch, and the version spec 30 | - Bumps the version 31 | - By default, uses [hatch](https://hatch.pypa.io/latest/), [tbump](https://github.com/tankerhq/tbump) or [bump2version](https://github.com/c4urself/bump2version) to bump the version based on presence of config files 32 | - We recommend `hatch` for most cases because it is very easy to set up. 33 | - Prepares the environment 34 | - Sets up git config and branch 35 | - Generates a changelog (using [github-activity](https://github.com/executablebooks/github-activity)) using the PRs since the last tag on this branch. 36 | - Gets the current version and then does a git checkout to clear state 37 | - Adds a new version entry using a HTML comment markers in the changelog file 38 | - Optionally resolves [meeseeks](https://github.com/MeeseeksBox/MeeseeksDev) backport PRs to their original PR 39 | - Creates a Draft GitHub release with the changelog changes and an attached 40 | metadata.json file capturing the inputs to the workflow. 41 | 42 | ### Populate Release Action 43 | 44 | - Input is typically the URL of the draft GitHub Release created in the Prep Release workflow, or no input to use the most recent draft release. 45 | - Fetches the `metadata.json` file and the changelog entry from the draft 46 | release. 47 | - Prepares the environment using the same method as the changelog action 48 | - Bumps the version 49 | - For Python packages: 50 | - Builds the wheel and source distributions if applicable 51 | - Makes sure Python dists can be installed and imported in a virtual environment 52 | - For npm package(s) (including workspace support): 53 | - Builds tarball(s) using `npm pack` 54 | - Make sure tarball(s) can be installed and imported in a new npm package 55 | - Adds a commit that includes the hashes of the dist files 56 | - Creates an annotated version tag in standard format 57 | - If given, bumps the version using the post version spec. he post version 58 | spec can also be given as a setting, [Write Releaser Config Guide](../how_to_guides/write_config.md). 59 | - Verifies that the SHA of the most recent commit has not changed on the target 60 | branch, preventing a mismatch of release commit. 61 | - Pushes the commits and tag to the target `branch` 62 | - Pusehes the created assets to the draft release, along with an `asset_shas.json` file capturing the checksums of the files. 63 | 64 | ### Finalize Release Action 65 | 66 | - Input is the url of the draft GitHub release from the Populate Release 67 | action. 68 | - Downloads the dist assets from the release 69 | - Verifies shas of release assets against the `asset_shas.json` file. 70 | - Publishes assets to appropriate registries. 71 | - Publishes the final GitHub release 72 | - If the tag is on a backport branch, makes a forwardport PR for the changelog entry 73 | 74 | Typically the Populate Release action and Finalize release action are 75 | run as part of the same workflow. 76 | 77 | ### Check Release Action 78 | 79 | - Runs on CI in the target repository to verify compatibility and release-ability. 80 | - Runs the `Draft Changelog` and `Draft Release` actions in dry run mode 81 | - Publishes to the local PyPI server and/or dry-run `npm publish`. 82 | - Does not make PRs or push git changes 83 | -------------------------------------------------------------------------------- /example-workflows/full-release.yml: -------------------------------------------------------------------------------- 1 | name: "Steps 1 + 2: Full Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | steps_to_skip: 27 | description: "Comma separated list of steps to skip during Populate Release" 28 | required: false 29 | jobs: 30 | prep_release: 31 | runs-on: ubuntu-latest 32 | permissions: 33 | contents: write 34 | steps: 35 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 36 | 37 | - name: Prep Release 38 | id: prep-release 39 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 40 | with: 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | version_spec: ${{ github.event.inputs.version_spec }} 43 | # silent: ${{ github.event.inputs.silent }} 44 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 45 | target: ${{ github.event.inputs.target }} 46 | branch: ${{ github.event.inputs.branch }} 47 | since: ${{ github.event.inputs.since }} 48 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 49 | 50 | publish_release: 51 | needs: [prep_release] 52 | runs-on: ubuntu-latest 53 | environment: release 54 | permissions: 55 | id-token: write 56 | steps: 57 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 58 | 59 | - uses: actions/create-github-app-token@v1 60 | id: app-token 61 | with: 62 | app-id: ${{ vars.APP_ID }} 63 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 64 | 65 | - name: Populate Release 66 | id: populate-release 67 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 68 | with: 69 | token: ${{ steps.app-token.outputs.token }} 70 | target: ${{ github.event.inputs.target }} 71 | branch: ${{ github.event.inputs.branch }} 72 | release_url: ${{ github.event.inputs.release_url }} 73 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 74 | 75 | - name: Finalize Release 76 | id: finalize-release 77 | env: 78 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 79 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 80 | with: 81 | token: ${{ steps.app-token.outputs.token }} 82 | target: ${{ github.event.inputs.target }} 83 | release_url: ${{ steps.populate-release.outputs.release_url }} 84 | 85 | - name: "** Next Step **" 86 | if: ${{ success() }} 87 | run: | 88 | echo "Verify the final release" 89 | echo ${{ steps.finalize-release.outputs.release_url }} 90 | 91 | - name: "** Failure Message **" 92 | if: ${{ failure() }} 93 | run: | 94 | echo "Failed to Publish the Draft Release Url:" 95 | echo ${{ steps.populate-release.outputs.release_url }} 96 | -------------------------------------------------------------------------------- /example-workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | # silent: 16 | # description: "Set a placeholder in the changelog and don't publish the release." 17 | # required: false 18 | # type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | # silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | branch: ${{ github.event.inputs.branch }} 43 | since: ${{ github.event.inputs.since }} 44 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 45 | 46 | - name: "** Next Step **" 47 | run: | 48 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 49 | -------------------------------------------------------------------------------- /example-workflows/publish-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Changelog" 2 | on: 3 | release: 4 | types: [published] 5 | 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: "The branch to target" 10 | required: false 11 | 12 | jobs: 13 | publish_changelog: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | steps: 17 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 18 | 19 | - uses: actions/create-github-app-token@v1 20 | id: app-token 21 | with: 22 | app-id: ${{ vars.APP_ID }} 23 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 24 | 25 | - name: Publish changelog 26 | id: publish-changelog 27 | uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 28 | with: 29 | token: ${{ steps.app-token.outputs.token }} 30 | branch: ${{ github.event.inputs.branch }} 31 | 32 | - name: "** Next Step **" 33 | run: | 34 | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" 35 | -------------------------------------------------------------------------------- /example-workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 44 | with: 45 | token: ${{ steps.app-token.outputs.token }} 46 | release_url: ${{ steps.populate-release.outputs.release_url }} 47 | 48 | - name: "** Next Step **" 49 | if: ${{ success() }} 50 | run: | 51 | echo "Verify the final release" 52 | echo ${{ steps.finalize-release.outputs.release_url }} 53 | 54 | - name: "** Failure Message **" 55 | if: ${{ failure() }} 56 | run: | 57 | echo "Failed to Publish the Draft Release Url:" 58 | echo ${{ steps.populate-release.outputs.release_url }} 59 | -------------------------------------------------------------------------------- /jupyter_releaser/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | __version__ = "1.9.0.dev0" 4 | -------------------------------------------------------------------------------- /jupyter_releaser/__main__.py: -------------------------------------------------------------------------------- 1 | """The main entry point for Jupyter Releaser.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | from jupyter_releaser.cli import main 5 | 6 | if __name__ == "__main__": 7 | main() 8 | -------------------------------------------------------------------------------- /jupyter_releaser/actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/jupyter_releaser/actions/__init__.py -------------------------------------------------------------------------------- /jupyter_releaser/actions/common.py: -------------------------------------------------------------------------------- 1 | """Common functions for actions.""" 2 | from contextlib import contextmanager 3 | 4 | from jupyter_releaser.util import prepare_environment 5 | from jupyter_releaser.util import run as _run 6 | 7 | 8 | @contextmanager 9 | def make_group(name): 10 | """Make a collapsed group in the GitHub Actions log.""" 11 | print(f"::group::{name}") 12 | yield 13 | print("::endgroup::") 14 | 15 | 16 | def setup(fetch_draft_release=True): 17 | """Common setup tasks for actions.""" 18 | with make_group("Prepare Environment"): 19 | return prepare_environment(fetch_draft_release=fetch_draft_release) 20 | 21 | 22 | def run_action(target, *args, **kwargs): 23 | """Run an action.""" 24 | with make_group(target): 25 | _run(target, *args, **kwargs) 26 | -------------------------------------------------------------------------------- /jupyter_releaser/actions/finalize_release.py: -------------------------------------------------------------------------------- 1 | """Finalize a release.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | import os 5 | 6 | from jupyter_releaser.actions.common import run_action, setup 7 | 8 | setup() 9 | 10 | release_url = os.environ["RH_RELEASE_URL"] 11 | 12 | if release_url: 13 | run_action("jupyter-releaser extract-release") 14 | 15 | run_action("jupyter-releaser publish-assets") 16 | 17 | if release_url: 18 | run_action("jupyter-releaser forwardport-changelog") 19 | run_action("jupyter-releaser publish-release") 20 | -------------------------------------------------------------------------------- /jupyter_releaser/actions/generate_changelog.py: -------------------------------------------------------------------------------- 1 | """Generate a changelog.""" 2 | import os 3 | from pathlib import Path 4 | 5 | from jupyter_releaser.actions.common import run_action 6 | from jupyter_releaser.changelog import get_version_entry 7 | from jupyter_releaser.util import CHECKOUT_NAME, get_branch, handle_since, log 8 | 9 | target = os.environ.get("RH_REPOSITORY") 10 | branch = os.environ.get("RH_BRANCH", "") 11 | ref = os.environ.get("RH_REF") 12 | since = handle_since() 13 | until = os.environ.get("RH_UNTIL") 14 | convert_to_rst = os.environ.get("RH_CONVERT_TO_RST", "") 15 | 16 | log("Generating changelog") 17 | log("target:", target) 18 | log("branch:", branch) 19 | log("ref:", ref) 20 | log("since:", since) 21 | log("until:", until) 22 | log("convert to rst:", convert_to_rst.lower() == "true") 23 | 24 | run_action("jupyter-releaser prep-git") 25 | branch = get_branch() 26 | orig_dir = os.getcwd() 27 | os.chdir(CHECKOUT_NAME) 28 | output = get_version_entry(ref, branch, target, "current", since=since, until=until) 29 | 30 | if convert_to_rst.lower() == "true": 31 | from pypandoc import convert_text # type:ignore[import-not-found] 32 | 33 | output = convert_text(output, "rst", "markdown") 34 | log("\n\n------------------------------") 35 | log(output, "------------------------------\n\n") 36 | os.chdir(orig_dir) 37 | Path("CHANGELOG_ENTRY.md").write_text(output, encoding="utf-8") 38 | -------------------------------------------------------------------------------- /jupyter_releaser/actions/populate_release.py: -------------------------------------------------------------------------------- 1 | """Populate a release.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | 5 | import os 6 | import sys 7 | 8 | from jupyter_releaser.actions.common import run_action, setup 9 | from jupyter_releaser.util import actions_output, get_gh_object, log, release_for_url 10 | 11 | setup() 12 | 13 | # Skip if we already have asset shas. 14 | release_url = os.environ["RH_RELEASE_URL"] 15 | owner, repo = os.environ["RH_REPOSITORY"].split("/") 16 | auth = os.environ["GITHUB_ACCESS_TOKEN"] 17 | gh = get_gh_object(False, owner=owner, repo=repo, token=auth) 18 | release = release_for_url(gh, release_url) 19 | for asset in release.assets: 20 | if asset.name == "asset_shas.json": 21 | log("Skipping populate assets") 22 | actions_output("release_url", release_url) 23 | sys.exit(0) 24 | 25 | dry_run = os.environ.get("RH_DRY_RUN", "").lower() == "true" 26 | 27 | if not os.environ.get("RH_RELEASE_URL"): 28 | msg = "Cannot complete Draft Release, no draft GitHub release url found!" 29 | raise RuntimeError(msg) 30 | 31 | run_action("jupyter-releaser prep-git") 32 | run_action("jupyter-releaser ensure-sha") 33 | run_action("jupyter-releaser bump-version") 34 | run_action("jupyter-releaser extract-changelog") 35 | 36 | # Make sure npm comes before python in case it produces 37 | # files for the python package 38 | run_action("jupyter-releaser build-npm") 39 | run_action("jupyter-releaser check-npm") 40 | run_action("jupyter-releaser build-python") 41 | run_action("jupyter-releaser check-python") 42 | run_action("jupyter-releaser tag-release") 43 | run_action("jupyter-releaser ensure-sha") 44 | run_action("jupyter-releaser populate-release") 45 | -------------------------------------------------------------------------------- /jupyter_releaser/actions/prep_release.py: -------------------------------------------------------------------------------- 1 | """Prepare a release.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | import os 5 | 6 | from jupyter_releaser.actions.common import make_group, run_action, setup 7 | from jupyter_releaser.util import CHECKOUT_NAME, get_default_branch, handle_since 8 | 9 | setup(False) 10 | 11 | run_action("jupyter-releaser prep-git") 12 | 13 | # Handle the branch. 14 | if not os.environ.get("RH_BRANCH"): 15 | cur_dir = os.getcwd() 16 | os.chdir(CHECKOUT_NAME) 17 | os.environ["RH_BRANCH"] = get_default_branch() or "" 18 | os.chdir(cur_dir) 19 | 20 | # Capture the "since" variable in case we add tags before checking changelog 21 | # Do this before bumping the version. 22 | with make_group("Handle RH_SINCE"): 23 | handle_since() 24 | 25 | run_action("jupyter-releaser bump-version") 26 | run_action("jupyter-releaser build-changelog") 27 | run_action("jupyter-releaser draft-changelog") 28 | -------------------------------------------------------------------------------- /jupyter_releaser/actions/publish_changelog.py: -------------------------------------------------------------------------------- 1 | """Remove silent placeholder entries in the changelog.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | import os 5 | 6 | from jupyter_releaser.actions.common import run_action, setup 7 | from jupyter_releaser.util import CHECKOUT_NAME, get_default_branch 8 | 9 | setup(False) 10 | 11 | run_action("jupyter-releaser prep-git") 12 | 13 | # Handle the branch. 14 | if not os.environ.get("RH_BRANCH"): 15 | cur_dir = os.getcwd() 16 | os.chdir(CHECKOUT_NAME) 17 | os.environ["RH_BRANCH"] = get_default_branch() or "" 18 | os.chdir(cur_dir) 19 | 20 | run_action("jupyter-releaser publish-changelog") 21 | -------------------------------------------------------------------------------- /jupyter_releaser/changelog.py: -------------------------------------------------------------------------------- 1 | """Changelog utilities for Jupyter Releaser.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | import re 5 | from pathlib import Path 6 | from typing import Optional 7 | 8 | import mdformat 9 | from fastcore.net import HTTP404NotFoundError # type:ignore[import-untyped] 10 | from github_activity import generate_activity_md # type:ignore[import-untyped] 11 | 12 | from jupyter_releaser import util 13 | 14 | START_MARKER = "" 15 | END_MARKER = "" 16 | START_SILENT_MARKER = "" 17 | END_SILENT_MARKER = "" 18 | PR_PREFIX = "Automated Changelog Entry" 19 | PRECOMMIT_PREFIX = "[pre-commit.ci] pre-commit autoupdate" 20 | 21 | 22 | def format_pr_entry(target, number, auth=None, dry_run=False): 23 | """Format a PR entry in the style used by our changelogs. 24 | 25 | Parameters 26 | ---------- 27 | target : str 28 | The GitHub owner/repo 29 | number : int 30 | The PR number to resolve 31 | auth : str, optional 32 | The GitHub authorization token 33 | dry_run: bool, optional 34 | Whether this is a dry run. 35 | 36 | Returns 37 | ------- 38 | str 39 | A formatted PR entry 40 | """ 41 | owner, repo = target.split("/") 42 | gh = util.get_gh_object(dry_run=dry_run, owner=owner, repo=repo, token=auth) 43 | pull = gh.pulls.get(number) 44 | title = pull.title 45 | url = pull.html_url 46 | user_name = pull.user.login 47 | user_url = pull.user.html_url 48 | return f"- {title} [#{number}]({url}) ([@{user_name}]({user_url}))" 49 | 50 | 51 | def get_version_entry( 52 | ref, 53 | branch, 54 | repo, 55 | version, 56 | *, 57 | since=None, 58 | since_last_stable=None, 59 | until=None, 60 | auth=None, 61 | resolve_backports=False, # noqa: ARG001 62 | dry_run=False, 63 | ): 64 | """Get a changelog for the changes since the last tag on the given branch. 65 | 66 | Parameters 67 | ---------- 68 | branch : str 69 | The target branch 70 | ref: str 71 | The source reference 72 | repo : str 73 | The GitHub owner/repo 74 | version : str 75 | The new version 76 | since: str 77 | Use PRs with activity since this date or git reference 78 | since_last_stable: 79 | Use PRs with activity since the last stable git tag 80 | until: str, optional 81 | Use PRs until this date or git reference 82 | auth : str, optional 83 | The GitHub authorization token 84 | resolve_backports: bool, optional 85 | Whether to resolve backports to the original PR 86 | dry_run: bool, optional 87 | Whether this is a dry run. 88 | 89 | Returns 90 | ------- 91 | str 92 | A formatted changelog entry with markers 93 | """ 94 | branch = branch or util.get_branch() 95 | since = since or util.get_latest_tag(ref or branch, since_last_stable) 96 | 97 | if since == "": 98 | since = util.get_first_commit(ref or branch) 99 | 100 | util.log(f"Getting changes to {repo} since {since} on branch {branch}...") 101 | 102 | until = until.replace("%", "") if until else None 103 | 104 | md = generate_activity_md( 105 | repo, 106 | since=since, 107 | until=until, 108 | kind="pr", 109 | heading_level=2, 110 | auth=auth, 111 | branch=branch, 112 | ) 113 | 114 | if not md: 115 | util.log("No PRs found") 116 | return f"## {version}\n\nNo merged PRs" 117 | 118 | entry = md.replace("[full changelog]", "[Full Changelog]") 119 | 120 | entry = entry.replace("...None", f"...{until}") if until else entry.replace("...None", "") 121 | 122 | entry = entry.splitlines()[2:] 123 | 124 | for ind, line in enumerate(entry): 125 | # Look for a backport, either manual or automatic. 126 | match = re.search(r"Backport PR #(\d+) on branch", line) 127 | if match: 128 | entry[ind] = format_pr_entry(repo, match.groups()[0], auth=auth, dry_run=dry_run) 129 | 130 | # Remove github actions PRs 131 | gh_actions = "[@github-actions](https://github.com/github-actions)" 132 | entry = [e for e in entry if gh_actions not in e] 133 | 134 | # Remove automated changelog PRs 135 | entry = [e for e in entry if PR_PREFIX not in e] 136 | 137 | # Remove Pre-Commit PRs 138 | entry = [e for e in entry if PRECOMMIT_PREFIX not in e] 139 | 140 | entry = "\n".join(entry).strip() 141 | 142 | # Remove empty documentation entry if only automated changelogs were there 143 | if "# Documentation improvements" in entry and "# Documentation improvements\n\n-" not in entry: 144 | entry = re.sub(r"#+ Documentation improvements\n\n", "", entry) 145 | 146 | return f""" 147 | ## {version} 148 | 149 | {entry} 150 | """.strip() 151 | 152 | 153 | def build_entry( 154 | ref, branch, repo, auth, changelog_path, since, since_last_stable, resolve_backports 155 | ): 156 | """Build a python version entry""" 157 | branch = branch or util.get_branch() 158 | repo = repo or util.get_repo() 159 | 160 | # Get the new version 161 | version = util.get_version() 162 | 163 | # Get changelog entry 164 | entry = get_version_entry( 165 | ref, 166 | branch, 167 | repo, 168 | version, 169 | since=since, 170 | since_last_stable=since_last_stable, 171 | auth=auth, 172 | resolve_backports=resolve_backports, 173 | ) 174 | update_changelog(changelog_path, entry) 175 | 176 | 177 | def update_changelog(changelog_path, entry, silent=False): 178 | """Update a changelog with a new entry.""" 179 | # Get the new version 180 | version = util.get_version() 181 | 182 | # Get the existing changelog and run some validation 183 | changelog = Path(changelog_path).read_text(encoding="utf-8") 184 | 185 | if START_MARKER not in changelog or END_MARKER not in changelog: 186 | msg = "Missing insert marker for changelog" 187 | raise ValueError(msg) 188 | 189 | if changelog.find(START_MARKER) != changelog.rfind(START_MARKER): 190 | msg = "Insert marker appears more than once in changelog" 191 | raise ValueError(msg) 192 | 193 | changelog = insert_entry(changelog, entry, version=version, silent=silent) 194 | Path(changelog_path).write_text(changelog, encoding="utf-8") 195 | 196 | 197 | def remove_placeholder_entries( 198 | repo: str, 199 | auth: Optional[str], 200 | changelog_path: str, 201 | dry_run: bool, 202 | ) -> int: 203 | """Replace any silent marker with the GitHub release body 204 | if the release has been published. 205 | 206 | Parameters 207 | ---------- 208 | repo : str 209 | The GitHub owner/repo 210 | auth : str 211 | The GitHub authorization token 212 | changelog_path : str 213 | The changelog file path 214 | dry_run: bool 215 | 216 | Returns 217 | ------- 218 | int 219 | Number of placeholders removed 220 | """ 221 | 222 | changelog = Path(changelog_path).read_text(encoding="utf-8") 223 | start_count = changelog.count(START_SILENT_MARKER) 224 | end_count = changelog.count(END_SILENT_MARKER) 225 | if start_count != end_count: 226 | msg = "" 227 | raise ValueError(msg) 228 | 229 | repo = repo or util.get_repo() 230 | owner, repo_name = repo.split("/") 231 | gh = util.get_gh_object(dry_run=dry_run, owner=owner, repo=repo_name, token=auth) 232 | 233 | # Replace silent placeholder by release body if it has been published 234 | previous_index = None 235 | changes_count = 0 236 | for _ in range(start_count): 237 | start = changelog.index(START_SILENT_MARKER, previous_index) 238 | end = changelog.index(END_SILENT_MARKER, start) 239 | 240 | version = _extract_version(changelog[start + len(START_SILENT_MARKER) : end]) 241 | try: 242 | util.log(f"Getting release for tag '{version}'...") 243 | release = gh.repos.get_release_by_tag(owner=owner, repo=repo_name, tag=f"v{version}") 244 | except HTTP404NotFoundError: 245 | # Skip this version 246 | pass 247 | else: 248 | if not release.draft: 249 | changelog_text = mdformat.text(release.body) 250 | changelog = ( 251 | changelog[:start] 252 | + f"\n\n{changelog_text}\n\n" 253 | + changelog[end + len(END_SILENT_MARKER) :] 254 | ) 255 | changes_count += 1 256 | 257 | previous_index = end 258 | 259 | # Write back the new changelog 260 | Path(changelog_path).write_text(format(changelog), encoding="utf-8") 261 | return changes_count 262 | 263 | 264 | def insert_entry( 265 | changelog: str, entry: str, version: Optional[str] = None, silent: bool = False 266 | ) -> str: 267 | """Insert the entry into the existing changelog.""" 268 | # Test if we are augmenting an existing changelog entry (for new PRs) 269 | # Preserve existing PR entries since we may have formatted them 270 | if silent: 271 | entry = f"{START_SILENT_MARKER}\n\n## {version}\n\n{END_SILENT_MARKER}" 272 | 273 | new_entry = f"{START_MARKER}\n\n{entry}\n\n{END_MARKER}" 274 | prev_entry = changelog[ 275 | changelog.index(START_MARKER) : changelog.index(END_MARKER) + len(END_MARKER) 276 | ] 277 | 278 | if f"# {version}\n" in prev_entry: 279 | lines = new_entry.splitlines() 280 | old_lines = prev_entry.splitlines() 281 | for ind, line in enumerate(lines): 282 | pr = re.search(r"\[#\d+\]", line) 283 | if not pr: 284 | continue 285 | for old_line in old_lines: 286 | if pr.group() in old_line: 287 | lines[ind] = old_line 288 | changelog = changelog.replace(prev_entry, "\n".join(lines)) 289 | else: 290 | changelog = changelog.replace(END_MARKER, "") 291 | changelog = changelog.replace(START_MARKER, new_entry) 292 | 293 | return format(changelog) 294 | 295 | 296 | def format(changelog: str) -> str: 297 | """Clean up changelog formatting""" 298 | changelog = re.sub(r"\n\n+", r"\n\n", changelog) 299 | return re.sub(r"\n\n+$", r"\n", changelog) 300 | 301 | 302 | def check_entry( 303 | ref, 304 | branch, 305 | repo, 306 | auth, 307 | changelog_path, 308 | since, 309 | since_last_stable, 310 | resolve_backports, 311 | output, 312 | ): 313 | """Check changelog entry""" 314 | branch = branch or util.get_branch() 315 | 316 | # Get the new version 317 | version = util.get_version() 318 | 319 | # Finalize changelog 320 | changelog = Path(changelog_path).read_text(encoding="utf-8") 321 | 322 | start = changelog.find(START_MARKER) 323 | end = changelog.find(END_MARKER) 324 | 325 | if start == -1 or end == -1: # pragma: no cover 326 | msg = "Missing new changelog entry delimiter(s)" 327 | raise ValueError(msg) 328 | 329 | if start != changelog.rfind(START_MARKER): # pragma: no cover 330 | msg = "Insert marker appears more than once in changelog" 331 | raise ValueError(msg) 332 | 333 | final_entry = changelog[start + len(START_MARKER) : end] 334 | 335 | repo = repo or util.get_repo() 336 | 337 | raw_entry = get_version_entry( 338 | ref, 339 | branch, 340 | repo, 341 | version, 342 | since=since, 343 | since_last_stable=since_last_stable, 344 | auth=auth, 345 | resolve_backports=resolve_backports, 346 | ) 347 | 348 | if f"# {version}" not in final_entry: # pragma: no cover 349 | util.log(final_entry) 350 | msg = f"Did not find entry for {version}" 351 | raise ValueError(msg) 352 | 353 | final_prs = re.findall(r"\[#(\d+)\]", final_entry) 354 | raw_prs = re.findall(r"\[#(\d+)\]", raw_entry) 355 | 356 | for pr in raw_prs: 357 | # Allow for changelog PR to not be in changelog itself 358 | skip = False 359 | for line in raw_entry.splitlines(): 360 | if f"[#{pr}]" in line and "changelog" in line.lower(): 361 | skip = True 362 | break 363 | if skip: 364 | continue 365 | if f"[#{pr}]" not in final_entry: # pragma: no cover 366 | msg = f"Missing PR #{pr} in changelog" 367 | raise ValueError(msg) 368 | for pr in final_prs: 369 | if f"[#{pr}]" not in raw_entry: # pragma: no cover 370 | msg = f"PR #{pr} does not belong in changelog for {version}" 371 | raise ValueError(msg) 372 | 373 | if output: 374 | Path(output).write_text(final_entry, encoding="utf-8") 375 | 376 | 377 | def splice_github_entry(orig_entry, github_entry): 378 | """Splice an entry created on GitHub into one created by build_entry""" 379 | 380 | # Override PR titles 381 | gh_regex = re.compile(r"^\* (.*?) by @.*?/pull/(\d+)$", flags=re.MULTILINE) 382 | cl_regex = re.compile(r"^- (.*?) \[#(\d+)\]") 383 | 384 | lut = {} 385 | for title, pr in re.findall(gh_regex, github_entry): 386 | lut[pr] = title 387 | 388 | lines = orig_entry.splitlines() 389 | for ind, line in enumerate(lines): 390 | match = re.match(cl_regex, line) 391 | if not match: 392 | continue 393 | title, pr = re.findall(cl_regex, line)[0] 394 | if pr in lut: 395 | lines[ind] = line.replace(title, lut[pr]) 396 | 397 | # Handle preamble 398 | preamble_index = github_entry.index("## What's Changed") 399 | if preamble_index > 0: 400 | preamble = github_entry[:preamble_index] 401 | if preamble.startswith("# "): 402 | preamble = preamble.replace("# ", "## ") 403 | if preamble.startswith("## "): 404 | preamble = preamble.replace("## ", "### ") 405 | 406 | lines = [*preamble.splitlines(), "", *lines] 407 | 408 | return "\n".join(lines) 409 | 410 | 411 | def extract_current(changelog_path): 412 | """Extract the current changelog entry""" 413 | body = "" 414 | if changelog_path and Path(changelog_path).exists(): 415 | changelog = Path(changelog_path).read_text(encoding="utf-8") 416 | 417 | start = changelog.find(START_MARKER) 418 | end = changelog.find(END_MARKER) 419 | if start != -1 and end != -1: 420 | body = changelog[start + len(START_MARKER) : end] 421 | return body 422 | 423 | 424 | def extract_current_version(changelog_path): 425 | """Extract the current released version from the changelog""" 426 | body = extract_current(changelog_path) 427 | return _extract_version(body) 428 | 429 | 430 | def _extract_version(entry: str) -> str: 431 | """Extract version from entry""" 432 | match = re.match(r"#+ (\d\S+)", entry.strip()) 433 | if not match: 434 | msg = "Could not find previous version" 435 | raise ValueError(msg) 436 | return match.groups()[0] 437 | -------------------------------------------------------------------------------- /jupyter_releaser/mock_github.py: -------------------------------------------------------------------------------- 1 | """A mock GitHub API implementation.""" 2 | import atexit 3 | import json 4 | import os 5 | import tempfile 6 | import uuid 7 | from datetime import datetime, timezone 8 | from typing import Dict, List 9 | 10 | from fastapi import FastAPI, Request 11 | from fastapi.staticfiles import StaticFiles 12 | from pydantic import BaseModel 13 | 14 | from jupyter_releaser.util import get_mock_github_url 15 | 16 | app = FastAPI() 17 | 18 | if "RH_GITHUB_STATIC_DIR" in os.environ: 19 | static_dir = os.environ["RH_GITHUB_STATIC_DIR"] 20 | else: 21 | static_dir_obj = tempfile.TemporaryDirectory() 22 | atexit.register(static_dir_obj.cleanup) 23 | static_dir = static_dir_obj.name 24 | 25 | app.mount("/static", StaticFiles(directory=static_dir), name="static") 26 | 27 | 28 | def load_from_file(name, klass): 29 | """Load data from a file to a model.""" 30 | source_file = os.path.join(static_dir, name + ".json") 31 | if not os.path.exists(source_file): 32 | return {} 33 | with open(source_file) as fid: 34 | data = json.load(fid) 35 | results = {} 36 | for key in data: 37 | if issubclass(klass, BaseModel): 38 | results[key] = klass(**data[key]) 39 | else: 40 | results[key] = data[key] 41 | return results 42 | 43 | 44 | def write_to_file(name, data): 45 | """Write model data to a file.""" 46 | source_file = os.path.join(static_dir, name + ".json") 47 | result = {} 48 | for key in data: 49 | value = data[key] 50 | if isinstance(value, BaseModel): 51 | if hasattr(value, "model_dump_json"): 52 | value = json.loads(value.model_dump_json()) 53 | else: 54 | value = json.loads(value.json()) 55 | result[key] = value 56 | with open(source_file, "w") as fid: 57 | json.dump(result, fid) 58 | 59 | 60 | class Asset(BaseModel): 61 | """An asset model.""" 62 | 63 | id: int 64 | name: str 65 | content_type: str 66 | size: int 67 | state: str = "uploaded" 68 | url: str 69 | node_id: str = "" 70 | download_count: int = 0 71 | label: str = "" 72 | uploader: None = None 73 | browser_download_url: str = "" 74 | created_at: str = "" 75 | updated_at: str = "" 76 | 77 | 78 | class Release(BaseModel): 79 | """A release model.""" 80 | 81 | assets_url: str = "" 82 | upload_url: str 83 | tarball_url: str = "" 84 | zipball_url: str = "" 85 | created_at: str 86 | published_at: str = "" 87 | draft: bool 88 | body: str = "" 89 | id: int 90 | node_id: str = "" 91 | author: str = "" 92 | html_url: str 93 | name: str = "" 94 | prerelease: bool 95 | tag_name: str 96 | target_commitish: str 97 | assets: List[Asset] 98 | url: str 99 | 100 | 101 | class User(BaseModel): 102 | """A user model.""" 103 | 104 | login: str = "bar" 105 | html_url: str = "http://bar.com" 106 | 107 | 108 | class PullRequest(BaseModel): 109 | """A pull request model.""" 110 | 111 | number: int = 0 112 | html_url: str = "http://foo.com" 113 | title: str = "foo" 114 | user: User = User() 115 | 116 | 117 | class TagObject(BaseModel): 118 | """A tab object model.""" 119 | 120 | sha: str 121 | 122 | 123 | class Tag(BaseModel): 124 | """A tag model.""" 125 | 126 | ref: str 127 | object: TagObject 128 | 129 | 130 | releases: Dict[str, "Release"] = load_from_file("releases", Release) 131 | pulls: Dict[str, "PullRequest"] = load_from_file("pulls", PullRequest) 132 | release_ids_for_asset: Dict[str, str] = load_from_file("release_ids_for_asset", int) 133 | tag_refs: Dict[str, "Tag"] = load_from_file("tag_refs", Tag) 134 | 135 | 136 | @app.get("/") 137 | def read_root(): 138 | """Get the root handler.""" 139 | return {"Hello": "World"} 140 | 141 | 142 | @app.get("/repos/{owner}/{repo}/releases") 143 | def list_releases(owner: str, repo: str) -> List[Release]: 144 | """https://docs.github.com/en/rest/releases/releases#list-releases""" 145 | return list(releases.values()) 146 | 147 | 148 | @app.get("/repos/{owner}/{repo}/releases/tags/{tag}") 149 | def get_release_by_tag(owner: str, repo: str, tag: str) -> Release: 150 | """https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name""" 151 | return next(filter(lambda r: r.tag_name == tag, releases.values())) 152 | 153 | 154 | @app.post("/repos/{owner}/{repo}/releases") 155 | async def create_a_release(owner: str, repo: str, request: Request) -> Release: 156 | """https://docs.github.com/en/rest/releases/releases#create-a-release""" 157 | release_id = uuid.uuid4().int % (2**32 - 1) 158 | data = await request.json() 159 | base_url = get_mock_github_url() 160 | url = f"{base_url}/repos/{owner}/{repo}/releases/{release_id}" 161 | html_url = f"{base_url}/{owner}/{repo}/releases/tag/{data['tag_name']}" 162 | upload_url = f"{base_url}/repos/{owner}/{repo}/releases/{release_id}/assets" 163 | fmt_str = r"%Y-%m-%dT%H:%M:%SZ" 164 | created_at = datetime.now(tz=timezone.utc).strftime(fmt_str) 165 | model = Release( 166 | id=release_id, 167 | url=url, 168 | html_url=html_url, 169 | assets=[], 170 | upload_url=upload_url, 171 | created_at=created_at, 172 | **data, 173 | ) 174 | releases[str(model.id)] = model 175 | write_to_file("releases", releases) 176 | return model 177 | 178 | 179 | @app.patch("/repos/{owner}/{repo}/releases/{release_id}") 180 | async def update_a_release(owner: str, repo: str, release_id: int, request: Request) -> Release: 181 | """https://docs.github.com/en/rest/releases/releases#update-a-release""" 182 | data = await request.json() 183 | model = releases[str(release_id)] 184 | for name, value in data.items(): 185 | setattr(model, name, value) 186 | write_to_file("releases", releases) 187 | return model 188 | 189 | 190 | @app.post("/repos/{owner}/{repo}/releases/{release_id}/assets") 191 | async def upload_a_release_asset(owner: str, repo: str, release_id: int, request: Request) -> None: 192 | """https://docs.github.com/en/rest/releases/assets#upload-a-release-asset""" 193 | base_url = get_mock_github_url() 194 | model = releases[str(release_id)] 195 | asset_id = uuid.uuid4().int % (2**32 - 1) 196 | name = request.query_params["name"] 197 | with open(f"{static_dir}/{asset_id}", "wb") as fid: 198 | async for chunk in request.stream(): 199 | fid.write(chunk) 200 | headers = request.headers 201 | url = f"{base_url}/static/{asset_id}" 202 | asset = Asset( 203 | id=asset_id, 204 | name=name, 205 | size=int(headers["content-length"]), 206 | url=url, 207 | content_type=headers["content-type"], 208 | ) 209 | release_ids_for_asset[str(asset_id)] = str(release_id) 210 | model.assets.append(asset) 211 | write_to_file("releases", releases) 212 | write_to_file("release_ids_for_asset", release_ids_for_asset) 213 | 214 | 215 | @app.delete("/repos/{owner}/{repo}/releases/assets/{asset_id}") 216 | async def delete_a_release_asset(owner: str, repo: str, asset_id: int) -> None: 217 | """https://docs.github.com/en/rest/releases/assets#delete-a-release-asset""" 218 | release = releases[release_ids_for_asset[str(asset_id)]] 219 | os.remove(f"{static_dir}/{asset_id}") 220 | release.assets = [a for a in release.assets if a.id != asset_id] 221 | del release_ids_for_asset[str(asset_id)] 222 | write_to_file("releases", releases) 223 | write_to_file("release_ids_for_asset", release_ids_for_asset) 224 | 225 | 226 | @app.delete("/repos/{owner}/{repo}/releases/{release_id}") 227 | def delete_a_release(owner: str, repo: str, release_id: int) -> None: 228 | """https://docs.github.com/en/rest/releases/releases#delete-a-release""" 229 | del releases[str(release_id)] 230 | write_to_file("releases", releases) 231 | 232 | 233 | @app.get("/repos/{owner}/{repo}/pulls/{pull_number}") 234 | def get_a_pull_request(owner: str, repo: str, pull_number: int) -> PullRequest: 235 | """https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request""" 236 | if str(pull_number) not in pulls: 237 | pulls[str(pull_number)] = PullRequest() 238 | write_to_file("pulls", pulls) 239 | return pulls[str(pull_number)] 240 | 241 | 242 | @app.post("/repos/{owner}/{repo}/pulls") 243 | def create_a_pull_request(owner: str, repo: str) -> PullRequest: 244 | """https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request""" 245 | pull = PullRequest() 246 | pulls[str(pull.number)] = pull 247 | write_to_file("pulls", pulls) 248 | return pull 249 | 250 | 251 | @app.post("/repos/{owner}/{repo}/issues/{issue_number}/labels") 252 | def add_labels_to_an_issue(owner: str, repo: str, issue_number: int) -> BaseModel: 253 | """https://docs.github.com/en/rest/issues/labels#add-labels-to-an-issue""" 254 | 255 | class _Inner(BaseModel): 256 | pass 257 | 258 | return _Inner() 259 | 260 | 261 | @app.post("/repos/{owner}/{repo}/git/refs") 262 | async def create_tag_ref(owner: str, repo: str, request: Request) -> None: 263 | """https://docs.github.com/en/rest/git/refs#create-a-reference""" 264 | data = await request.json() 265 | tag_ref = data["ref"] 266 | sha = data["sha"] 267 | tag = Tag(ref=f"refs/tags/{tag_ref}", object=TagObject(sha=sha)) 268 | tag_refs[tag_ref] = tag 269 | write_to_file("tag_refs", tag_refs) 270 | 271 | 272 | @app.get("/repos/{owner}/{repo}/git/matching-refs/tags/{tag_ref}") 273 | def list_matching_references(owner: str, repo: str, tag_ref: str) -> List[Tag]: 274 | """https://docs.github.com/en/rest/git/refs#list-matching-references""" 275 | # raise ValueError("we should have an api to set a sha for a tag ref for tests") 276 | return [tag_refs[tag_ref]] 277 | -------------------------------------------------------------------------------- /jupyter_releaser/npm.py: -------------------------------------------------------------------------------- 1 | """npm-related utilities.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | import json 5 | import os 6 | import os.path as osp 7 | import shutil 8 | import tarfile 9 | from glob import glob 10 | from pathlib import Path 11 | from tempfile import TemporaryDirectory 12 | 13 | from jupyter_releaser import util 14 | 15 | PACKAGE_JSON = util.PACKAGE_JSON 16 | 17 | 18 | # Python 3.12+ gives a deprecation warning if TarFile.extraction_filter is None. 19 | # https://docs.python.org/3.12/library/tarfile.html#tarfile-extraction-filter 20 | if hasattr(tarfile, "fully_trusted_filter"): 21 | tarfile.TarFile.extraction_filter = staticmethod(tarfile.fully_trusted_filter) # type:ignore[assignment] 22 | 23 | 24 | def build_dist(package, dist_dir): 25 | """Build npm dist file(s) from a package""" 26 | # Clean the dist folder of existing npm tarballs 27 | os.makedirs(dist_dir, exist_ok=True) 28 | dest = Path(dist_dir) 29 | for pkg in glob(f"{dist_dir}/*.tgz"): 30 | os.remove(pkg) 31 | 32 | if osp.isdir(package): 33 | tarball = osp.join(package, util.run("npm pack", cwd=package).split("\n")[-1]) 34 | else: 35 | tarball = package 36 | 37 | data = extract_package(tarball) 38 | 39 | # Move the tarball into the dist folder if public 40 | if not data.get("private", False): 41 | shutil.move(str(tarball), str(dest)) 42 | elif osp.isdir(package): 43 | os.remove(tarball) 44 | 45 | if not osp.isdir(package): 46 | return 47 | 48 | if "workspaces" in data: 49 | paths = [] 50 | for path in _get_workspace_packages(data): 51 | package_json = path / "package.json" 52 | data = json.loads(package_json.read_text(encoding="utf-8")) 53 | if data.get("private", False): 54 | continue 55 | paths.append(str(osp.abspath(path)).replace(os.sep, "/")) 56 | if paths: 57 | util.run(f"npm pack {' '.join(paths)}", cwd=dest, quiet=True) 58 | else: 59 | util.log( 60 | "The NPM package defines 'workspaces' that does not contain any public package; this may be a mistake." 61 | ) 62 | 63 | 64 | def extract_dist(dist_dir, target, repo=""): 65 | """Extract dist files from a dist_dir into a target dir 66 | 67 | 68 | If `repo` is provided, check that the repository URL is ending by it. 69 | """ 70 | names = [] 71 | paths = sorted(glob(f"{dist_dir}/*.tgz")) 72 | util.log(f"Extracting {len(paths)} packages...") 73 | 74 | for package in paths: 75 | path = Path(package) 76 | 77 | data = extract_package(path) 78 | name = data["name"] 79 | 80 | if repo and os.name != "nt": 81 | data_repository = data.get("repository", {}) 82 | if isinstance(data_repository, str): 83 | url = data_repository 84 | else: 85 | url = data_repository.get("url", "") 86 | if url.endswith(".git"): 87 | url = url[:-4] 88 | if not url.endswith(repo): 89 | msg = f"package.json for '{name}' does not define a 'repository.url' matching the cloned repository '{repo}'." 90 | raise ValueError(msg) 91 | 92 | # Skip if it is a private package 93 | if data.get("private", False): # pragma: no cover 94 | util.log(f"Skipping private package {name}") 95 | continue 96 | 97 | names.append(name) 98 | 99 | pkg_dir = target / name 100 | if not pkg_dir.parent.exists(): 101 | os.makedirs(pkg_dir.parent) 102 | 103 | tar = tarfile.open(path) 104 | tar.extractall(target) # noqa: S202 105 | tar.close() 106 | 107 | if "main" in data: 108 | main = osp.join(target, "package", data["main"]) 109 | if not osp.exists(main): 110 | msg = f"{name} is missing 'main' file {data['main']}" 111 | raise ValueError(msg) 112 | 113 | shutil.move(str(target / "package"), str(pkg_dir)) 114 | 115 | return names 116 | 117 | 118 | def check_dist(dist_dir, install_options, repo): 119 | """Check npm dist file(s) in a dist dir""" 120 | repo = repo or util.get_repo() 121 | 122 | with TemporaryDirectory() as td: 123 | util.run("npm init -y", cwd=td, quiet=True) 124 | names = [] 125 | staging = Path(td) / "staging" 126 | 127 | names = extract_dist(dist_dir, staging, repo) 128 | 129 | install_str = " ".join(f"./staging/{name}" for name in names) 130 | 131 | util.run(f"npm install {install_options} {install_str}", cwd=td, quiet=True) 132 | 133 | 134 | def extract_package(path): 135 | """Get the package json info from the tarball""" 136 | fid = tarfile.open(path) 137 | fidfile = fid.extractfile("package/package.json") 138 | assert fidfile is not None 139 | data = fidfile.read() 140 | fidfile.close() 141 | data = json.loads(data.decode("utf-8")) 142 | fid.close() 143 | return data 144 | 145 | 146 | def handle_npm_config(npm_token): 147 | """Handle npm_config""" 148 | npmrc = Path("~/.npmrc").expanduser() 149 | registry = os.environ.get("NPM_REGISTRY", "https://registry.npmjs.org/") 150 | reg_entry = text = f"registry={registry}" 151 | auth_entry = "" 152 | if npm_token: 153 | short_reg = registry.replace("https://", "//") 154 | short_reg = short_reg.replace("http://", "//") 155 | auth_entry = f"{short_reg}:_authToken={npm_token}" 156 | 157 | # Handle existing config 158 | if npmrc.exists(): 159 | text = npmrc.read_text(encoding="utf-8") 160 | if reg_entry in text: 161 | reg_entry = "" 162 | if auth_entry in text: 163 | auth_entry = "" 164 | 165 | text += f"\n{reg_entry}\n{auth_entry}" 166 | 167 | if os.environ.get(util.GH_ID_TOKEN_TOKEN_VAR, ""): 168 | util.log("Turning on NPM provenance as id-token permission is set.") 169 | # See documentation https://docs.npmjs.com/generating-provenance-statements 170 | # Also https://github.blog/2023-04-19-introducing-npm-package-provenance/ 171 | text += "\nprovenance=true" 172 | 173 | text = text.strip() + "\n" 174 | util.log(f"writing npm config to {npmrc}") 175 | npmrc.write_text(text, encoding="utf-8") 176 | 177 | 178 | def get_package_versions(version): 179 | """Get the formatted list of npm package names and versions""" 180 | message = "" 181 | data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) 182 | npm_version = data.get("version", "") 183 | if npm_version != version: 184 | message += f"\nPython version: {version}" 185 | message += f'\nnpm version: {data["name"]}: {npm_version}' 186 | if "workspaces" in data: 187 | message += "\nnpm workspace versions:" 188 | for path in _get_workspace_packages(data): 189 | text = path.joinpath("package.json").read_text(encoding="utf-8") 190 | data = json.loads(text) 191 | message += f'\n{data["name"]}: {data.get("version", "")}' 192 | return message 193 | 194 | 195 | def tag_workspace_packages(): 196 | """Generate tags for npm workspace packages""" 197 | if not PACKAGE_JSON.exists(): 198 | return 199 | 200 | data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) 201 | tags = util.run("git tag", quiet=True).splitlines() 202 | if "workspaces" not in data: 203 | return 204 | 205 | skipped = [] 206 | for path in _get_workspace_packages(data): 207 | sub_package_json = path / "package.json" 208 | sub_data = json.loads(sub_package_json.read_text(encoding="utf-8")) 209 | # Don't tag package without version or private 210 | if not sub_data.get("version", "") or sub_data.get("private", False): 211 | continue 212 | tag_name = f"{sub_data['name']}@{sub_data['version']}" 213 | if tag_name in tags: 214 | skipped.append(tag_name) 215 | else: 216 | util.run(f"git tag {tag_name}") 217 | if skipped: 218 | print(f"\nSkipped existing tags:\n{skipped}\n") 219 | 220 | 221 | def _get_workspace_packages(data): 222 | """Get the workspace package paths for a package given package data""" 223 | if isinstance(data["workspaces"], dict): 224 | patterns = [] 225 | for value in data["workspaces"].values(): 226 | patterns.extend(value) 227 | else: 228 | patterns = data["workspaces"] 229 | 230 | paths = [] 231 | for pattern in patterns: 232 | for path in glob(pattern, recursive=True): 233 | sub_package = Path(path) 234 | if not sub_package.is_dir(): 235 | continue 236 | sub_package_json = sub_package / "package.json" 237 | if not sub_package_json.exists(): 238 | continue 239 | paths.append(sub_package) 240 | 241 | return sorted(paths) 242 | -------------------------------------------------------------------------------- /jupyter_releaser/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/jupyter_releaser/py.typed -------------------------------------------------------------------------------- /jupyter_releaser/python.py: -------------------------------------------------------------------------------- 1 | """Python-related utilities.""" 2 | # Copyright (c) Jupyter Development Team. 3 | # Distributed under the terms of the Modified BSD License. 4 | import atexit 5 | import json 6 | import os 7 | import os.path as osp 8 | import re 9 | import shlex 10 | from glob import glob 11 | from io import BytesIO 12 | from pathlib import Path 13 | from subprocess import PIPE, CalledProcessError, Popen 14 | from tempfile import TemporaryDirectory 15 | from typing import cast 16 | 17 | import requests 18 | 19 | from jupyter_releaser import util 20 | 21 | PYPROJECT = util.PYPROJECT 22 | SETUP_PY = util.SETUP_PY 23 | 24 | PYPI_GH_API_TOKEN_URL = "https://pypi.org/_/oidc/github/mint-token" # noqa: S105 25 | 26 | 27 | def build_dist(dist_dir, clean=True): 28 | """Build the python dist files into a dist folder""" 29 | # Clean the dist folder of existing npm tarballs 30 | os.makedirs(dist_dir, exist_ok=True) 31 | dest = Path(dist_dir) 32 | if clean: 33 | for pkg in glob(f"{dest}/*.gz") + glob(f"{dest}/*.whl"): 34 | os.remove(pkg) 35 | 36 | util.run(f"pipx run build --outdir {dest} .", quiet=True, show_cwd=True) 37 | 38 | 39 | def check_dist( 40 | dist_file, 41 | test_cmd="", 42 | python_imports=None, 43 | check_cmd="pipx run twine check --strict {dist_file}", 44 | extra_check_cmds=None, 45 | resource_paths=None, 46 | ): 47 | """Check a Python package locally (not as a cli)""" 48 | resource_paths = resource_paths or [] 49 | dist_file = util.normalize_path(dist_file) 50 | dist_dir = os.path.dirname(dist_file) # used for check cmds. 51 | 52 | for cmd in [check_cmd, *list(extra_check_cmds or [])]: 53 | util.run(cmd.format(**locals())) 54 | 55 | test_commands = [] 56 | 57 | if test_cmd: 58 | test_commands.append(test_cmd) 59 | elif python_imports is not None: 60 | test_commands.extend([f'python -c "import {name}"' for name in python_imports]) 61 | else: 62 | # Get the package name from the dist file name 63 | match = re.match(r"(\S+)-\d", osp.basename(dist_file)) 64 | assert match is not None 65 | name = match.groups()[0] 66 | name = name.replace("-", "_") 67 | test_commands.append(f'python -c "import {name}"') 68 | 69 | # Create venvs to install dist file 70 | # run the test command in the venv 71 | with TemporaryDirectory() as td: 72 | env_path = util.normalize_path(osp.abspath(td)) 73 | if os.name == "nt": # pragma: no cover # noqa: SIM108 74 | bin_path = f"{env_path}/Scripts/" 75 | else: 76 | bin_path = f"{env_path}/bin" 77 | 78 | # Create the virtual env, upgrade pip, 79 | # install, and run test command 80 | util.run(f"python -m venv {env_path}") 81 | util.run(f"{bin_path}/python -m pip install -q -U pip") 82 | util.run(f"{bin_path}/pip install -q {dist_file}") 83 | try: 84 | for cmd in test_commands: 85 | util.run(f"{bin_path}/{cmd}") 86 | except CalledProcessError as e: 87 | if test_cmd == "": 88 | util.log( 89 | 'You may need to set "check_imports" to appropriate Python package names in the config file.' 90 | ) 91 | raise e 92 | for resource_path in resource_paths: 93 | name, _, _ = resource_path.partition("/") 94 | test_file = Path(td) / "test_path.py" 95 | test_text = f""" 96 | from importlib.metadata import PackagePath, files 97 | assert PackagePath('{resource_path}') in files('{name}') 98 | """ 99 | test_file.write_text(test_text, encoding="utf-8") 100 | test_file = util.normalize_path(test_file) 101 | cmd = f"{bin_path}/python {test_file}" 102 | util.run(cmd) 103 | 104 | 105 | def fetch_pypi_api_token() -> "str": 106 | """Fetch the PyPI API token for trusted publishers 107 | 108 | This implements the manual steps described in https://docs.pypi.org/trusted-publishers/using-a-publisher/ 109 | as of June 19th, 2023. 110 | 111 | It returns an empty string if it fails. 112 | """ 113 | util.log("Fetching PyPI OIDC token...") 114 | 115 | url = os.environ.get(util.GH_ID_TOKEN_URL_VAR, "") 116 | auth = os.environ.get(util.GH_ID_TOKEN_TOKEN_VAR, "") 117 | if not url or not auth: 118 | util.log( 119 | "Please verify that you have granted `id-token: write` permission to the publish workflow." 120 | ) 121 | return "" 122 | 123 | headers = {"Authorization": f"bearer {auth}", "Accept": "application/octet-stream"} 124 | 125 | sink = BytesIO() 126 | with requests.get(f"{url}&audience=pypi", headers=headers, stream=True, timeout=60) as r: 127 | r.raise_for_status() 128 | for chunk in r.iter_content(chunk_size=8192): 129 | sink.write(chunk) 130 | sink.seek(0) 131 | oidc_token = json.loads(sink.read().decode("utf-8")).get("value", "") 132 | 133 | if not oidc_token: 134 | util.log("Failed to fetch the OIDC token from PyPI.") 135 | return "" 136 | 137 | util.log("Fetching PyPI API token...") 138 | sink = BytesIO() 139 | with requests.post(PYPI_GH_API_TOKEN_URL, json={"token": oidc_token}, timeout=10) as r: 140 | r.raise_for_status() 141 | for chunk in r.iter_content(chunk_size=8192): 142 | sink.write(chunk) 143 | sink.seek(0) 144 | api_token = json.loads(sink.read().decode("utf-8")).get("token", "") 145 | 146 | return cast(str, api_token) 147 | 148 | 149 | def get_pypi_token(release_url, python_package): 150 | """Get the PyPI token 151 | 152 | Note: Do not print the token in CI since it will not be sanitized 153 | if it comes from the PYPI_TOKEN_MAP""" 154 | trusted_token = os.environ.get(util.GH_ID_TOKEN_TOKEN_VAR, "") 155 | 156 | if trusted_token: 157 | return fetch_pypi_api_token() 158 | 159 | twine_pwd = os.environ.get("PYPI_TOKEN", "") 160 | pypi_token_map = os.environ.get("PYPI_TOKEN_MAP", "").replace(r"\n", "\n") 161 | if pypi_token_map and release_url: 162 | parts = ( 163 | release_url.replace(util.get_mock_github_url() + "/", "") 164 | .replace("https://github.com/", "") 165 | .split("/") 166 | ) 167 | repo_name = f"{parts[0]}/{parts[1]}" 168 | if python_package != ".": 169 | repo_name += f"/{python_package}" 170 | util.log(f"Looking for PYPI token for {repo_name} in token map") 171 | for line in pypi_token_map.splitlines(): 172 | name, _, token = line.partition(",") 173 | if name == repo_name: 174 | twine_pwd = token.strip() 175 | util.log(f"Found PYPI token in map ending in {twine_pwd[-5:]}") 176 | elif twine_pwd: 177 | util.log("Using PYPI token from PYPI_TOKEN") 178 | else: 179 | util.log("PYPI token not found") 180 | 181 | return twine_pwd 182 | 183 | 184 | def start_local_pypi(): 185 | """Start a local PyPI server""" 186 | temp_dir = TemporaryDirectory() 187 | cmd = f"pypi-server run -p 8081 -P . -a . -o -v {temp_dir.name}" 188 | proc = Popen(shlex.split(cmd), stdout=PIPE) # noqa: S603 189 | # Wait for the server to start 190 | while True: 191 | assert proc.stdout is not None 192 | line = proc.stdout.readline().decode("utf-8").strip() 193 | util.log(line) 194 | if "Listening on" in line: 195 | break 196 | atexit.register(proc.kill) 197 | atexit.register(temp_dir.cleanup) 198 | -------------------------------------------------------------------------------- /jupyter_releaser/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Jupyter Releaser Metadata", 3 | "version": "0.1.0", 4 | "description": "Jupyter Releaser configuration metadata", 5 | "properties": { 6 | "skip": { 7 | "title": "Skip Steps", 8 | "description": "A list of steps to skip in actions", 9 | "type": "array", 10 | "items": { 11 | "type": "string" 12 | } 13 | }, 14 | "options": { 15 | "title": "Overrides for default options", 16 | "description": "Overrides for cli option names", 17 | "additionalProperties": { 18 | "anyOf": [ 19 | { "type": "string" }, 20 | { 21 | "type": "array", 22 | "items": { 23 | "type": "string" 24 | } 25 | } 26 | ] 27 | } 28 | }, 29 | "hooks": { 30 | "title": "Action Step Hooks", 31 | "description": "Hooks to run before or after action steps", 32 | "patternProperties": { 33 | "^(before|after)-.*$": { 34 | "anyOf": [ 35 | { "type": "string" }, 36 | { 37 | "type": "array", 38 | "items": { 39 | "type": "string" 40 | } 41 | } 42 | ] 43 | } 44 | } 45 | } 46 | }, 47 | "additionalProperties": false, 48 | "type": "object" 49 | } 50 | -------------------------------------------------------------------------------- /jupyter_releaser/tee.py: -------------------------------------------------------------------------------- 1 | """tee like run implementation.""" 2 | # This file is a modified version of https://github.com/pycontribs/subprocess-tee/blob/daffcbbf49fc5a2c7f3eaf75551f08fac0b9b63d/src/subprocess_tee/__init__.py 3 | # 4 | # It is licensed under the following license: 5 | # 6 | # The MIT License 7 | # Copyright (c) 2020 Sorin Sbarnea 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | import asyncio 24 | import atexit 25 | import os 26 | import platform 27 | import subprocess 28 | import sys 29 | from asyncio import StreamReader 30 | from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union 31 | 32 | if TYPE_CHECKING: 33 | CompletedProcess = subprocess.CompletedProcess[Any] # pylint: disable=E1136 34 | else: 35 | CompletedProcess = subprocess.CompletedProcess 36 | 37 | try: 38 | from shlex import join 39 | except ImportError: 40 | from subprocess import list2cmdline as join # type:ignore[assignment] 41 | 42 | 43 | STREAM_LIMIT = 2**23 # 8MB instead of default 64kb, override it if you need 44 | 45 | 46 | async def _read_stream(stream: StreamReader, callback: Callable[..., Any]) -> None: 47 | while True: 48 | line = await stream.readline() 49 | if line: 50 | callback(line) 51 | else: 52 | break 53 | 54 | 55 | async def _stream_subprocess(args: str, **kwargs: Any) -> CompletedProcess: 56 | platform_settings: Dict[str, Any] = {} 57 | if platform.system() == "Windows": 58 | platform_settings["env"] = os.environ 59 | 60 | # this part keeps behavior backwards compatible with subprocess.run 61 | tee = kwargs.get("tee", True) 62 | stdout = kwargs.get("stdout", sys.stdout) 63 | if stdout == subprocess.DEVNULL or not tee: 64 | stdout = open(os.devnull, "w") # noqa: SIM115 65 | stderr = kwargs.get("stderr", sys.stderr) 66 | if stderr == subprocess.DEVNULL or not tee: 67 | stderr = open(os.devnull, "w") # noqa: SIM115 68 | 69 | # We need to tell subprocess which shell to use when running shell-like 70 | # commands. 71 | # * SHELL is not always defined 72 | # * /bin/bash does not exit on alpine, /bin/sh seems bit more portable 73 | if "executable" not in kwargs and isinstance(args, str) and " " in args: # type:ignore[redundant-expr] 74 | platform_settings["executable"] = os.environ.get("SHELL", "/bin/sh") 75 | 76 | # pass kwargs we know to be supported 77 | for arg in ["cwd", "env"]: 78 | if arg in kwargs: 79 | platform_settings[arg] = kwargs[arg] 80 | 81 | # Some users are reporting that default (undocumented) limit 64k is too 82 | # low 83 | process = await asyncio.create_subprocess_shell( 84 | args, 85 | limit=STREAM_LIMIT, 86 | stdin=kwargs.get("stdin", False), 87 | stdout=asyncio.subprocess.PIPE, 88 | stderr=asyncio.subprocess.PIPE, 89 | **platform_settings, 90 | ) 91 | out: List[str] = [] 92 | err: List[str] = [] 93 | 94 | def tee_func(line: bytes, sink: List[str], pipe: Optional[Any]) -> None: # noqa: ARG001 95 | line_str = line.decode("utf-8").rstrip() 96 | sink.append(line_str) 97 | if not kwargs.get("quiet", False): 98 | # This is modified from the default implementation since 99 | # we want all output to be interleved on the same stream 100 | print(line_str, file=sys.stderr) 101 | 102 | loop = asyncio.get_running_loop() 103 | tasks = [] 104 | if process.stdout: 105 | tasks.append( 106 | loop.create_task(_read_stream(process.stdout, lambda li: tee_func(li, out, stdout))) 107 | ) 108 | if process.stderr: 109 | tasks.append( 110 | loop.create_task(_read_stream(process.stderr, lambda li: tee_func(li, err, stderr))) 111 | ) 112 | 113 | await asyncio.wait(set(tasks)) 114 | 115 | # We need to be sure we keep the stdout/stderr output identical with 116 | # the ones procued by subprocess.run(), at least when in text mode. 117 | check = kwargs.get("check", False) 118 | stdout = None if check else "" 119 | stderr = None if check else "" 120 | if out: 121 | stdout = os.linesep.join(out) + os.linesep 122 | if err: 123 | stderr = os.linesep.join(err) + os.linesep 124 | 125 | return CompletedProcess( 126 | args=args, 127 | returncode=await process.wait(), 128 | stdout=stdout, 129 | stderr=stderr, 130 | ) 131 | 132 | 133 | def run(args: Union[str, List[str]], **kwargs: Any) -> CompletedProcess: 134 | """Drop-in replacement for subprocess.run that behaves like tee. 135 | Extra arguments added by our version: 136 | echo: False - Prints command before executing it. 137 | quiet: False - Avoid printing output 138 | show_cwd: False - Prints the current working directory. 139 | """ 140 | if isinstance(args, str): # noqa: SIM108 141 | cmd = args 142 | else: 143 | # run was called with a list instead of a single item but asyncio 144 | # create_subprocess_shell requires command as a single string, so 145 | # we need to convert it to string 146 | cmd = join(args) 147 | 148 | check = kwargs.get("check", False) 149 | 150 | try: 151 | loop = asyncio.get_event_loop() 152 | except Exception: 153 | loop = asyncio.new_event_loop() 154 | asyncio.set_event_loop(loop) 155 | result = loop.run_until_complete(_stream_subprocess(cmd, **kwargs)) 156 | atexit.register(loop.close) 157 | 158 | if check and result.returncode != 0: 159 | raise subprocess.CalledProcessError( 160 | result.returncode, cmd, output=result.stdout, stderr=result.stderr 161 | ) 162 | return result 163 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.11"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyter_releaser" 7 | description = "Jupyter Releaser for Python and/or npm packages." 8 | license = {file = "LICENSE"} 9 | authors = [{name = "Jupyter Development Team", email = "jupyter@googlegroups.com"}] 10 | keywords = ["ipython", "jupyter"] 11 | classifiers = [ 12 | "Intended Audience :: Developers", 13 | "Intended Audience :: System Administrators", 14 | "Intended Audience :: Science/Research", 15 | "License :: OSI Approved :: BSD License", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | ] 23 | urls = {Homepage = "https://jupyter.org"} 24 | requires-python = ">=3.9" 25 | dynamic = ["version"] 26 | dependencies = [ 27 | "click<8.2.0", 28 | "ghapi<=1.0.4", 29 | "github-activity~=0.2", 30 | "importlib_resources", 31 | "jsonschema>=3.0.1", 32 | "mdformat", 33 | "packaging", 34 | "pkginfo", 35 | "pypiserver==2.2.0; python_version < '3.13'", 36 | "pypiserver; python_version >= '3.13'", 37 | "pipx", 38 | "requests", 39 | "requests_cache", 40 | "toml~=0.10", 41 | ] 42 | 43 | [project.readme] 44 | file = "README.md" 45 | content-type = "text/markdown" 46 | 47 | [project.optional-dependencies] 48 | docs = [ 49 | "sphinx", 50 | "sphinx-autobuild", 51 | "sphinx-copybutton", 52 | "pip", 53 | "myst-parser", 54 | "pydata_sphinx_theme", 55 | "sphinxcontrib_spelling", 56 | "numpydoc", 57 | "sphinx-click", 58 | ] 59 | test = [ 60 | "fastapi", 61 | "pre-commit", 62 | "pytest>=7.0", 63 | "pytest-mock", 64 | "pytest-xdist[psutil]", 65 | "uvicorn", 66 | "ruamel.yaml" 67 | ] 68 | 69 | [project.scripts] 70 | jupyter-releaser = "jupyter_releaser.cli:main" 71 | 72 | [tool.hatch.version] 73 | path = "jupyter_releaser/__init__.py" 74 | validate-bump = false 75 | 76 | [tool.hatch.envs.docs] 77 | features = ["docs"] 78 | [tool.hatch.envs.docs.scripts] 79 | build = "sphinx-build -W -b html docs/source docs/build/html" 80 | serve = "python -m http.server --directory docs/build/html" 81 | watch = "sphinx-autobuild -W -b html docs/source docs/build/html --host 0.0.0.0" 82 | 83 | [tool.hatch.envs.test] 84 | features = ["test"] 85 | [tool.hatch.envs.test.scripts] 86 | test = "python -m pytest -vv {args}" 87 | nowarn = "test -W default {args}" 88 | 89 | [tool.hatch.envs.cov] 90 | features = ["test"] 91 | dependencies = ["coverage[toml]", "pytest-cov"] 92 | [tool.hatch.envs.cov.scripts] 93 | test = "python -m pytest -vv --cov jupyter_releaser --cov-branch --cov-report term-missing:skip-covered {args}" 94 | nowarn = "test -W default {args}" 95 | 96 | [tool.hatch.envs.lint] 97 | detached = true 98 | dependencies = ["pre-commit"] 99 | [tool.hatch.envs.lint.scripts] 100 | build = "pre-commit run --all-files ruff" 101 | 102 | [tool.hatch.envs.typing] 103 | dependencies = [ "pre-commit"] 104 | detached = true 105 | [tool.hatch.envs.typing.scripts] 106 | test = "pre-commit run --all-files --hook-stage manual mypy" 107 | 108 | [tool.jupyter-releaser.hooks] 109 | after-populate-release = "bash ./.github/scripts/bump_tag.sh" 110 | 111 | [tool.jupyter-releaser.options] 112 | post-version-spec = "dev" 113 | 114 | [tool.pytest.ini_options] 115 | minversion = "6.0" 116 | xfail_strict = true 117 | log_cli_level = "info" 118 | addopts = [ 119 | "-ra", "--durations=10", "--color=yes", "--doctest-modules", 120 | "--showlocals", "--strict-markers", "--strict-config", 121 | "-p", "no:pastebin", "-p", "no:nose" 122 | ] 123 | testpaths = [ 124 | "tests/" 125 | ] 126 | filterwarnings= [ 127 | # Fail on warnings 128 | "error", 129 | "ignore:Using deprecated setup.py invocation:UserWarning", 130 | "module:Neither GITHUB_TOKEN nor GITHUB_JWT_TOKEN found:UserWarning", 131 | "module:datetime.datetime.utc:DeprecationWarning", 132 | "ignore:(?s).*Pyarrow will become a required dependency of pandas:DeprecationWarning", # pandas pyarrow (pandas<3.0), 133 | ] 134 | 135 | [tool.coverage.report] 136 | exclude_lines = [ 137 | "pragma: no cover", 138 | "def __repr__", 139 | "if self.debug:", 140 | "if settings.DEBUG", 141 | "raise AssertionError", 142 | "raise NotImplementedError", 143 | "if 0:", 144 | "if __name__ == .__main__.:", 145 | "class .*\bProtocol\\):", 146 | "@(abc\\.)?abstractmethod", 147 | ] 148 | 149 | [tool.coverage.run] 150 | relative_files = true 151 | source = ["jupyter_releaser"] 152 | 153 | [tool.mypy] 154 | files = "jupyter_releaser" 155 | python_version = "3.8" 156 | strict = true 157 | disable_error_code = [ "no-untyped-call", "no-untyped-def"] 158 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 159 | warn_unreachable = true 160 | exclude = ["tests"] 161 | 162 | [tool.ruff] 163 | target-version = "py38" 164 | line-length = 100 165 | 166 | [tool.ruff.lint] 167 | extend-select = [ 168 | "B", # flake8-bugbear 169 | "I", # isort 170 | "ARG", # flake8-unused-arguments 171 | "C4", # flake8-comprehensions 172 | "EM", # flake8-errmsg 173 | "ICN", # flake8-import-conventions 174 | "G", # flake8-logging-format 175 | "PGH", # pygrep-hooks 176 | "PIE", # flake8-pie 177 | "PL", # pylint 178 | #"PTH", # flake8-use-pathlib 179 | "PT", # flake8-pytest-style 180 | "RET", # flake8-return 181 | "RUF", # Ruff-specific 182 | "SIM", # flake8-simplify 183 | "T20", # flake8-print 184 | "UP", # pyupgrade 185 | "YTT", # flake8-2020 186 | "EXE", # flake8-executable 187 | "PYI", # flake8-pyi 188 | "S", # flake8-bandit 189 | ] 190 | ignore = [ 191 | "PLR", # Design related pylint codes 192 | "SIM105", # Use `contextlib.suppress(...)` 193 | "T201", # `print` found 194 | "S101", # Use of `assert` detected 195 | ] 196 | unfixable = [ 197 | # Don't touch print statements 198 | "T201", 199 | # Don't touch noqa lines 200 | "RUF100", 201 | ] 202 | 203 | [tool.ruff.lint.per-file-ignores] 204 | # B011 Do not call assert False since python -O removes these calls 205 | # F841 local variable 'foo' is assigned to but never used 206 | # C408 Unnecessary `dict` call 207 | # E402 Module level import not at top of file 208 | # T201 `print` found 209 | # B007 Loop control variable `i` not used within the loop body. 210 | # N802 Function name `assertIn` should be lowercase 211 | # S105 Possible hardcoded password 212 | "tests/*" = ["B011", "F841", "C408", "E402", "T201", "B007", "N802", "S105", "S106", "ARG", "PTH", "PT004", "PT011"] 213 | "jupyter_releaser/mock_github.py" = ["ARG"] 214 | 215 | [tool.interrogate] 216 | ignore-init-module=true 217 | ignore-private=true 218 | ignore-semiprivate=true 219 | ignore-property-decorators=true 220 | ignore-nested-functions=true 221 | ignore-nested-classes=true 222 | fail-under=100 223 | exclude = ["tests"] 224 | 225 | [tool.repo-review] 226 | ignore = ["GH102"] 227 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-server/jupyter_releaser/6accaa3c07b69acaa1e14e00ba138133d8cbe879/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | import json 4 | import os 5 | import os.path as osp 6 | import tempfile 7 | import time 8 | import uuid 9 | from pathlib import Path 10 | 11 | import pytest 12 | from click.testing import CliRunner 13 | from ghapi.core import GhApi 14 | 15 | from jupyter_releaser import cli, util 16 | from jupyter_releaser.util import ensure_mock_github, run 17 | from tests import util as testutil 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def github_port(worker_id): 22 | # The worker id will be of the form "gw123" unless xdist is disabled, 23 | # in which case it will be "master". 24 | if worker_id == "master": 25 | return 26 | os.environ["MOCK_GITHUB_PORT"] = str(8000 + int(worker_id[2:])) 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def mock_env(mocker): 31 | """Clear unwanted environment variables""" 32 | # Anything that starts with RH_ or GITHUB_ or PIP 33 | prefixes = ["GITHUB_", "RH_", "PIP_"] 34 | env = os.environ.copy() 35 | for key in list(env): 36 | for prefix in prefixes: 37 | if key.startswith(prefix): 38 | del env[key] 39 | 40 | mocker.patch.dict(os.environ, env, clear=True) 41 | return 42 | 43 | 44 | @pytest.fixture() 45 | def git_repo(tmp_path): 46 | prev_dir = os.getcwd() 47 | os.chdir(tmp_path) 48 | 49 | run("git init") 50 | run("git config user.name snuffy") 51 | run("git config user.email snuffy@sesame.com") 52 | 53 | run("git checkout -b foo") 54 | gitignore = tmp_path / ".gitignore" 55 | gitignore.write_text(f"dist/*\nbuild/*\n{util.CHECKOUT_NAME}\n", encoding="utf-8") 56 | 57 | changelog = tmp_path / "CHANGELOG.md" 58 | changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") 59 | 60 | config = Path(util.JUPYTER_RELEASER_CONFIG) 61 | config.write_text(testutil.TOML_CONFIG, encoding="utf-8") 62 | 63 | run("git add .") 64 | run('git commit -m "foo"') 65 | run(f"git remote add origin {util.normalize_path(tmp_path)}") 66 | run("git push origin foo") 67 | run("git remote set-head origin foo") 68 | run("git checkout -b bar foo") 69 | run("git fetch origin") 70 | yield tmp_path 71 | os.chdir(prev_dir) 72 | 73 | 74 | @pytest.fixture() 75 | def py_package(git_repo): 76 | return testutil.create_python_package(git_repo) 77 | 78 | 79 | @pytest.fixture() 80 | def py_multipackage(git_repo): 81 | return testutil.create_python_package(git_repo, multi=True) 82 | 83 | 84 | @pytest.fixture() 85 | def py_package_different_names(git_repo): 86 | return testutil.create_python_package(git_repo, not_matching_name=True) 87 | 88 | 89 | @pytest.fixture() 90 | def npm_package(git_repo): 91 | return testutil.create_npm_package(git_repo) 92 | 93 | 94 | @pytest.fixture() 95 | def workspace_package(npm_package): 96 | pkg_file = npm_package / "package.json" 97 | data = json.loads(pkg_file.read_text(encoding="utf-8")) 98 | data["workspaces"] = dict(packages=["packages/*"]) 99 | data["private"] = True 100 | pkg_file.write_text(json.dumps(data), encoding="utf-8") 101 | 102 | prev_dir = Path(os.getcwd()) 103 | for name in ["foo", "bar", "baz"]: 104 | new_dir = prev_dir / "packages" / name 105 | os.makedirs(new_dir) 106 | os.chdir(new_dir) 107 | run("npm init -y", quiet=True) 108 | index = new_dir / "index.js" 109 | index.write_text('console.log("hello")', encoding="utf-8") 110 | if name == "foo": 111 | pkg_json = new_dir / "package.json" 112 | sub_data = json.loads(pkg_json.read_text(encoding="utf-8")) 113 | sub_data["dependencies"] = dict(bar="*") 114 | sub_data["main"] = "index.js" 115 | sub_data["repository"] = dict(url=str(npm_package)) 116 | pkg_json.write_text(json.dumps(sub_data), encoding="utf-8") 117 | elif name == "baz": 118 | pkg_json = new_dir / "package.json" 119 | sub_data = json.loads(pkg_json.read_text(encoding="utf-8")) 120 | sub_data["dependencies"] = dict(foo="*") 121 | sub_data["main"] = "index.js" 122 | sub_data["repository"] = dict(url=str(npm_package)) 123 | pkg_json.write_text(json.dumps(sub_data), encoding="utf-8") 124 | elif name == "bar": 125 | pkg_json = new_dir / "package.json" 126 | sub_data = json.loads(pkg_json.read_text(encoding="utf-8")) 127 | sub_data["repository"] = dict(url=str(npm_package)) 128 | pkg_json.write_text(json.dumps(sub_data), encoding="utf-8") 129 | os.chdir(prev_dir) 130 | util.run("git add .") 131 | util.run('git commit -a -m "Add workspaces"') 132 | return npm_package 133 | 134 | 135 | @pytest.fixture() 136 | def py_dist(py_package, runner, mocker, build_mock, git_prep): 137 | changelog_entry = testutil.mock_changelog_entry(py_package, runner, mocker) 138 | 139 | # Create the dist files 140 | util.run("pipx run build .", cwd=util.CHECKOUT_NAME, quiet=True) 141 | 142 | # Finalize the release 143 | runner(["tag-release"]) 144 | 145 | return py_package 146 | 147 | 148 | @pytest.fixture() 149 | def npm_dist(workspace_package, runner, mocker, git_prep): 150 | changelog_entry = testutil.mock_changelog_entry(workspace_package, runner, mocker) 151 | 152 | # Create the dist files 153 | runner(["build-npm"]) 154 | 155 | # Finalize the release 156 | runner(["tag-release"]) 157 | 158 | return workspace_package 159 | 160 | 161 | @pytest.fixture() 162 | def runner(): 163 | cli_runner = CliRunner() 164 | 165 | def run(*args, **kwargs): 166 | result = cli_runner.invoke(cli.main, *args, **kwargs) 167 | if result.exit_code != 0: 168 | if result.stderr_bytes: 169 | print("Captured stderr\n", result.stderr, "\n\n") 170 | print("Captured stdout\n", result.stdout, "\n\n") 171 | assert result.exception is not None 172 | raise result.exception 173 | 174 | return result 175 | 176 | return run 177 | 178 | 179 | @pytest.fixture() 180 | def git_prep(runner, git_repo): 181 | runner(["prep-git", "--git-url", git_repo]) 182 | 183 | 184 | @pytest.fixture() 185 | def build_mock(mocker): 186 | orig_run = util.run 187 | 188 | def wrapped(cmd, **kwargs): 189 | if cmd == "pipx run build .": 190 | if osp.exists(util.CHECKOUT_NAME): 191 | dist_dir = Path(f"{util.CHECKOUT_NAME}/dist") 192 | else: 193 | dist_dir = Path("dist") 194 | os.makedirs(dist_dir, exist_ok=True) 195 | Path(f"{dist_dir}/foo-0.0.2a0.tar.gz").write_text("hello", encoding="utf-8") 196 | Path(f"{dist_dir}/foo-0.0.2a0-py3-none-any.whl").write_text("hello", encoding="utf-8") 197 | return "" 198 | return orig_run(cmd, **kwargs) 199 | 200 | mock_run = mocker.patch("jupyter_releaser.util.run", wraps=wrapped) 201 | 202 | 203 | @pytest.fixture() 204 | def mock_github(): 205 | proc = ensure_mock_github() 206 | yield proc 207 | 208 | if proc: 209 | proc.kill() 210 | proc.wait() 211 | 212 | 213 | @pytest.fixture() 214 | def release_metadata(): 215 | return dict( 216 | [(k, v) for k, v in testutil.BASE_RELEASE_METADATA.items() if k != "version"] 217 | + [("version", uuid.uuid4().hex)] 218 | ) 219 | 220 | 221 | @pytest.fixture() 222 | def draft_release(mock_github, release_metadata): 223 | gh = GhApi(owner="foo", repo="bar") 224 | data = release_metadata 225 | tag = "v" + data["version"] 226 | 227 | with tempfile.TemporaryDirectory() as d: 228 | metadata_path = Path(d) / "metadata.json" 229 | with open(metadata_path, "w") as fid: 230 | json.dump(data, fid) 231 | 232 | # Ensure this is the latest release. 233 | time.sleep(1) 234 | release = gh.create_release(tag, "bar", tag, "hi", True, True, files=[metadata_path]) 235 | yield release.html_url 236 | try: 237 | gh.repos.delete_release(release.id) 238 | except Exception as e: 239 | print(e) 240 | -------------------------------------------------------------------------------- /tests/test_changelog.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | import os 5 | import subprocess 6 | 7 | import pytest 8 | from ghapi.core import GhApi 9 | 10 | from jupyter_releaser.changelog import ( 11 | END_MARKER, 12 | END_SILENT_MARKER, 13 | START_MARKER, 14 | START_SILENT_MARKER, 15 | remove_placeholder_entries, 16 | update_changelog, 17 | ) 18 | from jupyter_releaser.util import release_for_url 19 | from tests import util as testutil 20 | 21 | 22 | @pytest.fixture() 23 | def module_template(): 24 | return testutil.PY_MODULE_TEMPLATE 25 | 26 | 27 | @pytest.fixture() 28 | def mock_py_package(tmp_path, module_template): 29 | pyproject = tmp_path / "pyproject.toml" 30 | pyproject.write_text(testutil.pyproject_template(), encoding="utf-8") 31 | 32 | foopy = tmp_path / "foo.py" 33 | foopy.write_text(module_template, encoding="utf-8") 34 | 35 | 36 | def test_update_changelog(tmp_path, mock_py_package): 37 | changelog = tmp_path / "CHANGELOG.md" 38 | changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") 39 | 40 | os.chdir(tmp_path) 41 | update_changelog(str(changelog), testutil.CHANGELOG_ENTRY) 42 | 43 | new_changelog = changelog.read_text(encoding="utf-8") 44 | 45 | assert f"{START_MARKER}\n{testutil.CHANGELOG_ENTRY}\n{END_MARKER}" in new_changelog 46 | 47 | 48 | def test_silent_update_changelog(tmp_path, mock_py_package): 49 | changelog = tmp_path / "CHANGELOG.md" 50 | changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") 51 | os.chdir(tmp_path) 52 | update_changelog(str(changelog), testutil.CHANGELOG_ENTRY, True) 53 | 54 | new_changelog = changelog.read_text(encoding="utf-8") 55 | 56 | assert ( 57 | f"{START_MARKER}\n\n{START_SILENT_MARKER}\n\n## 0.0.1\n\n{END_SILENT_MARKER}\n\n{END_MARKER}" 58 | in new_changelog 59 | ) 60 | 61 | 62 | def test_update_changelog_with_old_silent_entry(tmp_path, mock_py_package): 63 | changelog = tmp_path / "CHANGELOG.md" 64 | changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") 65 | os.chdir(tmp_path) 66 | 67 | # Update changelog for current version 68 | update_changelog(str(changelog), testutil.CHANGELOG_ENTRY, True) 69 | # Bump version 70 | subprocess.check_call(["pipx", "run", "hatch", "version", "patch"], cwd=tmp_path) # noqa: S603, S607 71 | # Update changelog for the new version 72 | update_changelog(str(changelog), testutil.CHANGELOG_ENTRY) 73 | 74 | new_changelog = changelog.read_text(encoding="utf-8") 75 | 76 | assert f"{START_SILENT_MARKER}\n\n## 0.0.1\n\n{END_SILENT_MARKER}" in new_changelog 77 | assert ( 78 | f"{START_MARKER}\n\n{START_SILENT_MARKER}\n\n## 0.0.1\n\n{END_SILENT_MARKER}\n\n{END_MARKER}" 79 | not in new_changelog 80 | ) 81 | 82 | 83 | @pytest.mark.parametrize("module_template", ['__version__ = "0.0.3"\n']) 84 | def test_silence_existing_changelog_entry(tmp_path, mock_py_package): 85 | changelog = tmp_path / "CHANGELOG.md" 86 | changelog.write_text(testutil.CHANGELOG_TEMPLATE, encoding="utf-8") 87 | os.chdir(tmp_path) 88 | update_changelog(str(changelog), testutil.CHANGELOG_ENTRY) 89 | 90 | new_changelog = changelog.read_text(encoding="utf-8") 91 | assert f"{START_MARKER}\n{testutil.CHANGELOG_ENTRY}\n{END_MARKER}" in new_changelog 92 | 93 | # Should silent the current entry 94 | update_changelog(str(changelog), testutil.CHANGELOG_ENTRY, True) 95 | 96 | new_changelog = changelog.read_text(encoding="utf-8") 97 | assert ( 98 | f"{START_MARKER}\n\n{START_SILENT_MARKER}\n\n## 0.0.3\n\n{END_SILENT_MARKER}\n\n{END_MARKER}" 99 | in new_changelog 100 | ) 101 | assert f"{START_MARKER}\n{testutil.CHANGELOG_ENTRY}\n{END_MARKER}" not in new_changelog 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "release_metadata", 106 | [ 107 | dict( 108 | [(k, v) for k, v in testutil.BASE_RELEASE_METADATA.items() if k != "version"] 109 | + [("version", "0.0.0")] 110 | ) 111 | ], 112 | ) 113 | def test_remove_placeholder_entries(tmp_path, release_metadata, draft_release): 114 | # Create changelog with silent placeholder 115 | changelog = tmp_path / "CHANGELOG.md" 116 | placeholder = ( 117 | f"\n{START_SILENT_MARKER}\n\n## {release_metadata['version']}\n\n{END_SILENT_MARKER}\n" 118 | ) 119 | changelog.write_text(testutil.CHANGELOG_TEMPLATE + placeholder, encoding="utf-8") 120 | os.chdir(tmp_path) 121 | 122 | # Publish the release (as it is a draft from `draft_release`) 123 | gh = GhApi(owner="foo", repo="bar") 124 | release = release_for_url(gh, draft_release) 125 | published_changelog = "Published body" 126 | gh.repos.update_release( 127 | release["id"], 128 | release["tag_name"], 129 | release["target_commitish"], 130 | release["name"], 131 | published_changelog, 132 | False, 133 | release["prerelease"], 134 | ) 135 | 136 | remove_placeholder_entries("foo/bar", None, changelog, False) 137 | 138 | new_changelog = changelog.read_text(encoding="utf-8") 139 | assert placeholder not in new_changelog 140 | assert published_changelog in new_changelog 141 | 142 | 143 | @pytest.mark.parametrize( 144 | "release_metadata", 145 | [ 146 | dict( 147 | [ 148 | (k, v) 149 | for k, v in testutil.BASE_RELEASE_METADATA.items() 150 | if k not in ("version", "silent") 151 | ] 152 | + [("version", "0.0.0"), ("silent", True)] 153 | ) 154 | ], 155 | ) 156 | def test_dont_remove_placeholder_entries(tmp_path, release_metadata, draft_release): 157 | changelog = tmp_path / "CHANGELOG.md" 158 | placeholder = ( 159 | f"\n{START_SILENT_MARKER}\n\n## {release_metadata['version']}\n\n{END_SILENT_MARKER}\n" 160 | ) 161 | changelog.write_text(testutil.CHANGELOG_TEMPLATE + placeholder, encoding="utf-8") 162 | os.chdir(tmp_path) 163 | 164 | # Release is not published, so this is a no-op 165 | remove_placeholder_entries("foo/bar", None, changelog, False) 166 | 167 | new_changelog = changelog.read_text(encoding="utf-8") 168 | assert placeholder in new_changelog 169 | assert "hi" not in new_changelog 170 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | import json 4 | import os 5 | import shutil 6 | import time 7 | from pathlib import Path 8 | 9 | import toml 10 | from ghapi.core import GhApi 11 | 12 | from jupyter_releaser import changelog, npm, util 13 | from jupyter_releaser.util import run 14 | from tests import util as testutil 15 | 16 | 17 | def test_get_branch(git_repo): 18 | assert util.get_branch() == "bar" 19 | run("git checkout foo") 20 | assert util.get_branch() == "foo" 21 | 22 | 23 | def test_get_repo(git_repo, mocker): 24 | repo = f"{git_repo.parent.name}/{git_repo.name}" 25 | assert util.get_repo() == repo 26 | 27 | 28 | def test_get_version_pyproject_hatch(py_package): 29 | assert util.get_version() == "0.0.1" 30 | util.bump_version("0.0.2a0") 31 | assert util.get_version() == "0.0.2a0" 32 | 33 | 34 | def test_get_version_multipython(py_multipackage): 35 | prev_dir = os.getcwd() 36 | for package in py_multipackage: 37 | os.chdir(package["rel_path"]) 38 | assert util.get_version() == "0.0.1" 39 | util.bump_version("0.0.2a0") 40 | assert util.get_version() == "0.0.2a0" 41 | os.chdir(prev_dir) 42 | 43 | 44 | def test_get_version_npm(npm_package): 45 | assert util.get_version() == "1.0.0" 46 | npm = util.normalize_path(shutil.which("npm")) 47 | run(f"{npm} version patch") 48 | assert util.get_version() == "1.0.1" 49 | 50 | 51 | def test_format_pr_entry(mock_github): 52 | gh = GhApi(owner="snuffy", repo="foo") 53 | info = gh.pulls.create("title", "head", "base", "body", True, False, None) 54 | resp = changelog.format_pr_entry("snuffy/foo", info["number"], auth="baz") 55 | assert resp.startswith("- ") 56 | 57 | 58 | def test_get_changelog_version_entry(py_package, mocker): 59 | version = util.get_version() 60 | 61 | mocked_gen = mocker.patch("jupyter_releaser.changelog.generate_activity_md") 62 | mocked_gen.return_value = testutil.CHANGELOG_ENTRY 63 | branch = "foo" 64 | util.run("git branch baz/bar") 65 | util.run("git tag v1.0 baz/bar") 66 | ref = "heads/baz/bar" 67 | resp = changelog.get_version_entry(ref, branch, "baz/bar", version) 68 | mocked_gen.assert_called_with( 69 | "baz/bar", 70 | since="v1.0", 71 | until=None, 72 | kind="pr", 73 | branch=branch, 74 | heading_level=2, 75 | auth=None, 76 | ) 77 | 78 | assert f"## {version}" in resp 79 | assert testutil.PR_ENTRY in resp 80 | 81 | mocked_gen.return_value = testutil.CHANGELOG_ENTRY 82 | resp = changelog.get_version_entry( 83 | ref, branch, "baz/bar", version, resolve_backports=True, auth="bizz" 84 | ) 85 | mocked_gen.assert_called_with( 86 | "baz/bar", 87 | since="v1.0", 88 | until=None, 89 | kind="pr", 90 | branch=branch, 91 | heading_level=2, 92 | auth="bizz", 93 | ) 94 | 95 | assert f"## {version}" in resp 96 | assert testutil.PR_ENTRY in resp 97 | 98 | 99 | def test_get_changelog_version_entry_no_tag(py_package, mocker): 100 | version = util.get_version() 101 | 102 | mocked_gen = mocker.patch("jupyter_releaser.changelog.generate_activity_md") 103 | mocked_gen.return_value = testutil.CHANGELOG_ENTRY 104 | branch = "foo" 105 | util.run("git branch baz/bar") 106 | commit = run("git rev-list --max-parents=0 HEAD", quiet=True) 107 | ref = "heads/baz/bar" 108 | resp = changelog.get_version_entry(ref, branch, "baz/bar", version) 109 | mocked_gen.assert_called_with( 110 | "baz/bar", 111 | since=commit, 112 | until=None, 113 | kind="pr", 114 | branch=branch, 115 | heading_level=2, 116 | auth=None, 117 | ) 118 | 119 | assert f"## {version}" in resp 120 | assert testutil.PR_ENTRY in resp 121 | 122 | mocked_gen.return_value = testutil.CHANGELOG_ENTRY 123 | resp = changelog.get_version_entry( 124 | ref, branch, "baz/bar", version, resolve_backports=True, auth="bizz" 125 | ) 126 | mocked_gen.assert_called_with( 127 | "baz/bar", 128 | since=commit, 129 | until=None, 130 | kind="pr", 131 | branch=branch, 132 | heading_level=2, 133 | auth="bizz", 134 | ) 135 | 136 | assert f"## {version}" in resp 137 | assert testutil.PR_ENTRY in resp 138 | 139 | 140 | def test_get_changelog_version_entry_since_last_stable(py_package, mocker): 141 | version = util.get_version() 142 | 143 | mocked_gen = mocker.patch("jupyter_releaser.changelog.generate_activity_md") 144 | mocked_gen.return_value = testutil.CHANGELOG_ENTRY 145 | branch = "foo" 146 | util.run("git branch baz/bar") 147 | util.run("git tag v1.0.0 baz/bar") 148 | util.run("git tag v1.1.0a0 baz/bar") 149 | ref = "heads/baz/bar" 150 | resp = changelog.get_version_entry(ref, branch, "baz/bar", version, since_last_stable=True) 151 | mocked_gen.assert_called_with( 152 | "baz/bar", 153 | since="v1.0.0", 154 | until=None, 155 | kind="pr", 156 | branch=branch, 157 | heading_level=2, 158 | auth=None, 159 | ) 160 | 161 | assert f"## {version}" in resp 162 | assert testutil.PR_ENTRY in resp 163 | 164 | 165 | def test_get_empty_changelog(py_package, mocker): 166 | mocked_gen = mocker.patch("jupyter_releaser.changelog.generate_activity_md") 167 | mocked_gen.return_value = testutil.EMPTY_CHANGELOG_ENTRY 168 | branch = "foo" 169 | util.run("git branch baz/bar") 170 | ref = "heads/baz/bar" 171 | resp = changelog.get_version_entry(ref, branch, "baz/bar", "0.2.5", since="v0.2.4") 172 | mocked_gen.assert_called_with( 173 | "baz/bar", 174 | since="v0.2.4", 175 | until=None, 176 | kind="pr", 177 | branch=branch, 178 | heading_level=2, 179 | auth=None, 180 | ) 181 | 182 | assert "...None" not in resp 183 | 184 | 185 | def test_splice_github_entry(py_package, mocker): 186 | version = util.get_version() 187 | 188 | mocked_gen = mocker.patch("jupyter_releaser.changelog.generate_activity_md") 189 | mocked_gen.return_value = testutil.CHANGELOG_ENTRY 190 | branch = "foo" 191 | util.run("git branch baz/bar") 192 | util.run("git tag v1.0.0 baz/bar") 193 | util.run("git tag v1.1.0a0 baz/bar") 194 | ref = "heads/baz/bar" 195 | resp = changelog.get_version_entry(ref, branch, "baz/bar", version, since_last_stable=True) 196 | 197 | updated = changelog.splice_github_entry(resp, testutil.GITHUB_CHANGELOG_ENTRY) 198 | 199 | assert "Defining contributions" in updated 200 | 201 | preamble = "# My title\nmy content\n" 202 | updated = changelog.splice_github_entry(resp, preamble + testutil.GITHUB_CHANGELOG_ENTRY) 203 | 204 | assert "Defining contributions" in updated 205 | assert preamble in updated 206 | 207 | 208 | def test_compute_sha256(py_package): 209 | assert len(util.compute_sha256(py_package / "CHANGELOG.md")) == 64 210 | 211 | 212 | def test_create_release_commit(py_package, build_mock): 213 | util.bump_version("0.0.2a0") 214 | version = util.get_version() 215 | util.run("pipx run build .") 216 | shas = util.create_release_commit(version) 217 | assert util.normalize_path("dist/foo-0.0.2a0.tar.gz") in shas 218 | assert util.normalize_path("dist/foo-0.0.2a0-py3-none-any.whl") in shas 219 | 220 | 221 | def test_create_release_commit_hybrid(py_package, build_mock): 222 | # Add an npm package and test with that 223 | util.bump_version("0.0.2a0") 224 | version = util.get_version() 225 | testutil.create_npm_package(py_package) 226 | pkg_json = py_package / "package.json" 227 | data = json.loads(pkg_json.read_text(encoding="utf-8")) 228 | data["version"] = version 229 | pkg_json.write_text(json.dumps(data, indent=4), encoding="utf-8") 230 | util.run("pre-commit run --all-files", check=False) 231 | txt = testutil.TBUMP_NPM_TEMPLATE 232 | (py_package / "tbump.toml").write_text(txt, encoding="utf-8") 233 | 234 | util.run("pipx run build .") 235 | shas = util.create_release_commit(version) 236 | assert len(shas) == 2 237 | assert util.normalize_path("dist/foo-0.0.2a0.tar.gz") in shas 238 | 239 | 240 | def test_handle_npm_config(npm_package): 241 | npmrc = Path("~/.npmrc").expanduser() 242 | existed = npmrc.exists() 243 | if existed: 244 | npmrc_text = npmrc.read_text(encoding="utf-8") 245 | npm.handle_npm_config("abc") 246 | text = npmrc.read_text(encoding="utf-8") 247 | assert "_authToken=abc" in text 248 | 249 | if existed: 250 | npmrc.write_text(npmrc_text, encoding="utf-8") 251 | 252 | 253 | def test_bump_version_reg(py_package): 254 | for spec in ["1.0.1", "1.0.3a4"]: 255 | util.bump_version(spec) 256 | util.run("git commit -a -m 'bump version'") 257 | assert util.get_version() == spec 258 | util.bump_version("1.0.2") 259 | util.bump_version("next") 260 | assert util.get_version() == "1.0.3" 261 | util.bump_version("patch") 262 | assert util.get_version() == "1.0.4" 263 | util.bump_version("1.0.3a5") 264 | util.bump_version("next") 265 | assert util.get_version() == "1.0.3a6" 266 | util.bump_version("minor") 267 | assert util.get_version() == "1.1.0" 268 | 269 | 270 | def test_bump_version_dev(py_package): 271 | util.bump_version("dev", changelog_path="CHANGELOG.md") 272 | assert util.get_version() == "0.1.0.dev0" 273 | util.bump_version("dev", changelog_path="CHANGELOG.md") 274 | assert util.get_version() == "0.1.0.dev1" 275 | util.bump_version("next", changelog_path="CHANGELOG.md") 276 | assert util.get_version() == "0.0.3" 277 | util.bump_version("dev", changelog_path="CHANGELOG.md") 278 | assert util.get_version() == "0.1.0.dev0" 279 | util.bump_version("patch", changelog_path="CHANGELOG.md") 280 | assert util.get_version() == "0.0.3" 281 | util.bump_version("dev", changelog_path="CHANGELOG.md") 282 | assert util.get_version() == "0.1.0.dev0" 283 | util.bump_version("minor", changelog_path="CHANGELOG.md") 284 | assert util.get_version() == "0.1.0" 285 | 286 | 287 | def test_get_config_python(py_package): 288 | Path(util.JUPYTER_RELEASER_CONFIG).unlink() 289 | text = util.PYPROJECT.read_text(encoding="utf-8") 290 | text = testutil.TOML_CONFIG.replace("\n[", "\n[tool.jupyter-releaser.") 291 | util.PYPROJECT.write_text(text, encoding="utf-8") 292 | config = util.read_config() 293 | assert "before-build-python" in config["hooks"]["before-build-python"] 294 | 295 | 296 | def test_get_config_npm(npm_package): 297 | Path(util.JUPYTER_RELEASER_CONFIG).unlink() 298 | package_json = util.PACKAGE_JSON 299 | data = json.loads(package_json.read_text(encoding="utf-8")) 300 | data["jupyter-releaser"] = toml.loads(testutil.TOML_CONFIG) 301 | package_json.write_text(json.dumps(data), encoding="utf-8") 302 | config = util.read_config() 303 | assert "before-build-npm" in config["hooks"]["before-build-npm"] 304 | 305 | 306 | def test_get_config_file(git_repo): 307 | config = util.read_config() 308 | assert "before-build-python" in config["hooks"]["before-build-python"] 309 | 310 | 311 | def test_get_latest_draft_release(mock_github): 312 | gh = GhApi(owner="foo", repo="bar") 313 | gh.create_release( 314 | "v1.0.0", 315 | "main", 316 | "v1.0.0", 317 | "body", 318 | True, 319 | True, 320 | files=[], 321 | ) 322 | latest = util.latest_draft_release(gh) 323 | assert latest.name == "v1.0.0" 324 | 325 | # Ensure a different timestamp. 326 | time.sleep(1) 327 | gh.create_release( 328 | "v1.1.0", 329 | "bob", 330 | "v1.1.0", 331 | "body", 332 | True, 333 | True, 334 | files=[], 335 | ) 336 | latest = util.latest_draft_release(gh) 337 | assert latest.name == "v1.1.0" 338 | latest = util.latest_draft_release(gh, "main") 339 | assert latest.name == "v1.0.0" 340 | 341 | 342 | def test_parse_release_url(): 343 | match = util.parse_release_url("https://github.com/foo/bar/releases/tag/fizz") 344 | assert match.groupdict() == {"owner": "foo", "repo": "bar", "tag": "fizz"} 345 | match = util.parse_release_url("https://api.github.com/repos/fizz/buzz/releases/tags/foo") 346 | assert match.groupdict() == {"owner": "fizz", "repo": "buzz", "tag": "foo"} 347 | match = util.parse_release_url( 348 | "https://github.com/foo/bar/releases/tag/untagged-8a3c19f85a0a51d3ea66" 349 | ) 350 | assert match.groupdict() == { 351 | "owner": "foo", 352 | "repo": "bar", 353 | "tag": "untagged-8a3c19f85a0a51d3ea66", 354 | } 355 | 356 | 357 | def test_extract_metadata_from_release_url(mock_github, draft_release): 358 | gh = GhApi(owner="foo", repo="bar") 359 | data = util.extract_metadata_from_release_url(gh, draft_release, "") 360 | assert os.environ["RH_BRANCH"] == data["branch"] 361 | 362 | 363 | def test_prepare_environment(mock_github, draft_release): 364 | os.environ["GITHUB_REPOSITORY"] = "foo/bar" 365 | tag = draft_release.split("/")[-1] 366 | os.environ["RH_BRANCH"] = "bar" 367 | os.environ["GITHUB_REF"] = f"refs/tag/{tag}" 368 | os.environ["RH_DRY_RUN"] = "true" 369 | data = util.prepare_environment() 370 | assert os.environ["RH_RELEASE_URL"] == draft_release 371 | assert data["version_spec"] == os.environ["RH_VERSION_SPEC"] 372 | 373 | 374 | def test_handle_since(npm_package, runner): 375 | runner(["prep-git", "--git-url", npm_package]) 376 | since = util.handle_since() 377 | assert not since 378 | 379 | run("git tag v1.0.1", cwd=util.CHECKOUT_NAME) 380 | since = util.handle_since() 381 | assert since == "v1.0.1" 382 | -------------------------------------------------------------------------------- /tests/test_mock_github.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | from ghapi.core import GhApi 5 | 6 | from jupyter_releaser.mock_github import Asset, Release, load_from_file, write_to_file 7 | 8 | 9 | def test_mock_github(mock_github): 10 | owner = "foo" 11 | repo_name = "bar" 12 | auth = "hi" 13 | 14 | gh = GhApi(owner=owner, repo=repo_name, token=auth) 15 | print(list(gh.repos.list_releases())) 16 | 17 | here = os.path.dirname(os.path.abspath(__file__)) 18 | files = [os.path.join(here, f) for f in os.listdir(here)] 19 | files = [f for f in files if not os.path.isdir(f)] 20 | 21 | release = gh.create_release( 22 | "v1.0.0", 23 | "main", 24 | "v1.0.0", 25 | "body", 26 | True, 27 | True, 28 | files=files, 29 | ) 30 | 31 | print(release.html_url) 32 | 33 | release = gh.repos.update_release( 34 | release["id"], 35 | release["tag_name"], 36 | release["target_commitish"], 37 | release["name"], 38 | "body", 39 | False, 40 | release["prerelease"], 41 | ) 42 | assert release.draft is False 43 | 44 | for asset in release.assets: 45 | headers = dict(Authorization=f"token {auth}", Accept="application/octet-stream") 46 | print(asset.name) 47 | with requests.get(asset.url, headers=headers, stream=True, timeout=60) as r: 48 | r.raise_for_status() 49 | for _ in r.iter_content(chunk_size=8192): 50 | pass 51 | 52 | gh.git.create_ref("v1.1.0", "aaaa") 53 | tags = gh.list_tags("v1.1.0") 54 | assert tags[0]["object"]["sha"] == "aaaa" 55 | 56 | gh.repos.delete_release(release.id) 57 | 58 | pull = gh.pulls.create("title", "head", "base", "body", True, False, None) 59 | gh.issues.add_labels(pull.number, ["documentation"]) 60 | 61 | 62 | def test_cache_storage(): 63 | asset = Asset( 64 | id=1, 65 | name="hi", 66 | size=122, 67 | url="hi", 68 | content_type="hi", 69 | ) 70 | model = Release( 71 | id=1, 72 | url="hi", 73 | html_url="ho", 74 | assets=[asset], 75 | upload_url="hi", 76 | created_at="1", 77 | draft=False, 78 | prerelease=False, 79 | target_commitish="1", 80 | tag_name="1", 81 | ) 82 | write_to_file("releases", dict(test=model)) 83 | data = load_from_file("releases", Release) 84 | assert isinstance(data["test"], Release) 85 | assert isinstance(data["test"].assets[0], Asset) 86 | assert data["test"].assets[0].url == asset.url 87 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | import json 4 | import os 5 | import shutil 6 | import tempfile 7 | from pathlib import Path 8 | 9 | from ghapi.core import GhApi 10 | from ruamel.yaml import YAML 11 | 12 | from jupyter_releaser import changelog, cli, util 13 | from jupyter_releaser.util import run 14 | 15 | VERSION_SPEC = "1.0.1" 16 | 17 | TOML_CONFIG = """ 18 | [hooks] 19 | """ 20 | 21 | for name in cli.main.commands: 22 | TOML_CONFIG += f"'before-{name}' = \"echo before-{name} >> 'log.txt'\"\n" 23 | TOML_CONFIG += f"'after-{name}' = \"echo after-{name} >> 'log.txt'\"\n" 24 | 25 | PR_ENTRY = "Mention the required GITHUB_ACCESS_TOKEN [#1](https://github.com/executablebooks/github-activity/pull/1) ([@consideRatio](https://github.com/consideRatio))" 26 | 27 | CHANGELOG_ENTRY = f""" 28 | ## master@{{2019-09-01}}...master@{{2019-11-01}} 29 | 30 | ([full changelog](https://github.com/executablebooks/github-activity/compare/479cc4b2f5504945021e3c4ee84818a10fabf810...ed7f1ed78b523c6b9fe6b3ac29e834087e299296)) 31 | 32 | ### Merged PRs 33 | 34 | - defining contributions [#14](https://github.com/executablebooks/github-activity/pull/14) ([@choldgraf](https://github.com/choldgraf)) 35 | - updating CLI for new tags [#12](https://github.com/executablebooks/github-activity/pull/12) ([@choldgraf](https://github.com/choldgraf)) 36 | - fixing link to changelog with refs [#11](https://github.com/executablebooks/github-activity/pull/11) ([@choldgraf](https://github.com/choldgraf)) 37 | - adding contributors list [#10](https://github.com/executablebooks/github-activity/pull/10) ([@choldgraf](https://github.com/choldgraf)) 38 | - some improvements to `since` and opened issues list [#8](https://github.com/executablebooks/github-activity/pull/8) ([@choldgraf](https://github.com/choldgraf)) 39 | - Support git references etc. [#6](https://github.com/executablebooks/github-activity/pull/6) ([@consideRatio](https://github.com/consideRatio)) 40 | - adding authentication information [#2](https://github.com/executablebooks/github-activity/pull/2) ([@choldgraf](https://github.com/choldgraf)) 41 | - {PR_ENTRY} 42 | 43 | ### Contributors to this release 44 | 45 | ([GitHub contributors page for this release](https://github.com/executablebooks/github-activity/graphs/contributors?from=2019-09-01&to=2019-11-01&type=c)) 46 | 47 | [@betatim](https://github.com/search?q=repo%3Aexecutablebooks%2Fgithub-activity+involves%3Abetatim+updated%3A2019-09-01..2019-11-01&type=Issues) | [@choldgraf](https://github.com/search?q=repo%3Aexecutablebooks%2Fgithub-activity+involves%3Acholdgraf+updated%3A2019-09-01..2019-11-01&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Aexecutablebooks%2Fgithub-activity+involves%3AconsideRatio+updated%3A2019-09-01..2019-11-01&type=Issues) 48 | """ 49 | 50 | EMPTY_CHANGELOG_ENTRY = """ 51 | ## main@{2021-09-15}...main@{2022-01-18} 52 | 53 | ([Full Changelog](https://github.com/QuantStack/jupyterlab-js-logs/compare/v0.2.4...None)) 54 | 55 | ### Contributors to this release 56 | 57 | ([GitHub contributors page for this release](https://github.com/QuantStack/jupyterlab-js-logs/graphs/contributors?from=2021-09-15&to=2022-01-18&type=c)) 58 | 59 | """ 60 | 61 | GITHUB_CHANGELOG_ENTRY = """ 62 | ## What's Changed 63 | * Some improvements to `since` and opened issues list @choldgraf in https://github.com/executablebooks/github-activity/pull/8 64 | * Defining contributions by @choldgraf in https://github.com/executablebooks/github-activity/pull/14 65 | * Fixing link to changelog with refs by @choldgraf in https://github.com/executablebooks/github-activity/pull/11 66 | 67 | 68 | **Full Changelog**: https://github.com/executablebooks/github-activity/compare/479cc4b2f5504945021e3c4ee84818a10fabf810...ed7f1ed78b523c6b9fe6b3ac29e834087e299296 69 | """ 70 | 71 | 72 | BASE_RELEASE_METADATA = dict( 73 | version_spec="foo", 74 | branch="bar", 75 | repo="fizz", 76 | since="buzz", 77 | since_last_stable=False, 78 | post_version_spec="dev", 79 | post_version_message="hi", 80 | silent=False, 81 | ) 82 | 83 | 84 | def setup_cfg_template(package_name="foo", module_name=None): 85 | return f""" 86 | [metadata] 87 | name = {package_name} 88 | version = attr: {module_name or package_name}.__version__ 89 | 90 | [options] 91 | zip_safe = False 92 | include_package_data = True 93 | py_modules = {module_name or package_name} 94 | """ 95 | 96 | 97 | SETUP_PY_TEMPLATE = """__import__("setuptools").setup()\n""" 98 | 99 | LICENSE_TEMPLATE = "A fake license\n" 100 | 101 | README_TEMPLATE = "A fake readme\n" 102 | 103 | 104 | def pyproject_template(project_name="foo", module_name="foo", sub_packages=None): 105 | sub_packages = sub_packages or [] 106 | res = f""" 107 | [build-system] 108 | requires = ["hatchling>=1.11"] 109 | build-backend = "hatchling.build" 110 | 111 | [project] 112 | name = "{project_name}" 113 | dynamic = ["version"] 114 | description = "My package description" 115 | readme = "README.md" 116 | license = {{file = "LICENSE"}} 117 | authors = [ 118 | {{email = "foo@foo.com"}}, 119 | {{name = "foo"}} 120 | ] 121 | 122 | [tool.hatch.version] 123 | path = "{module_name}.py" 124 | validate-bump = false 125 | 126 | [project.urls] 127 | homepage = "https://foo.com" 128 | """ 129 | if sub_packages: 130 | res += f""" 131 | [tools.jupyter-releaser.options] 132 | python_packages = {sub_packages} 133 | """ 134 | return res 135 | 136 | 137 | PY_MODULE_TEMPLATE = '__version__ = "0.0.1"\n' 138 | 139 | TBUMP_NPM_TEMPLATE = """ 140 | [[file]] 141 | src = "package.json" 142 | search = '"version": "{current_version}"' 143 | """ 144 | 145 | MANIFEST_TEMPLATE = """ 146 | include *.md 147 | include *.toml 148 | include *.yaml 149 | include LICENSE 150 | """ 151 | 152 | CHANGELOG_TEMPLATE = f"""# Changelog 153 | 154 | {changelog.START_MARKER} 155 | 156 | ## 0.0.2 157 | 158 | Second commit 159 | 160 | {changelog.END_MARKER} 161 | 162 | ## 0.0.1 163 | 164 | Initial commit 165 | """ 166 | 167 | 168 | def mock_changelog_entry(package_path, runner, mocker, version_spec=VERSION_SPEC): 169 | runner(["bump-version", "--version-spec", version_spec]) 170 | changelog_file = "CHANGELOG.md" 171 | changelog = Path(util.CHECKOUT_NAME) / changelog_file 172 | mocked_gen = mocker.patch("jupyter_releaser.changelog.generate_activity_md") 173 | mocked_gen.return_value = CHANGELOG_ENTRY 174 | runner(["build-changelog", "--changelog-path", changelog_file]) 175 | return changelog_file 176 | 177 | 178 | def create_npm_package(git_repo): 179 | npm = util.normalize_path(shutil.which("npm")) 180 | run(f"{npm} init -y") 181 | 182 | # Add the npm provenance info. 183 | pack_json = Path(git_repo / "package.json") 184 | with pack_json.open() as fid: 185 | data = json.load(fid) 186 | data["repository"] = dict(url=str(git_repo)) 187 | with pack_json.open("w") as fid: 188 | json.dump(data, fid) 189 | 190 | git_repo.joinpath("index.js").write_text('console.log("hello");\n', encoding="utf-8") 191 | 192 | run("git add .") 193 | run('git commit -m "initial npm package"') 194 | 195 | run("git checkout foo") 196 | run("git pull origin bar", quiet=True) 197 | run("git checkout bar") 198 | return git_repo 199 | 200 | 201 | def get_log(): 202 | log = Path(util.CHECKOUT_NAME) / "log.txt" 203 | return log.read_text(encoding="utf-8").splitlines() 204 | 205 | 206 | def create_python_package(git_repo, multi=False, not_matching_name=False): 207 | def write_files(git_repo, sub_packages=None, package_name="foo", module_name=None): 208 | sub_packages = sub_packages or [] 209 | 210 | module_name = module_name or package_name 211 | 212 | pyproject = git_repo / "pyproject.toml" 213 | pyproject.write_text( 214 | pyproject_template(package_name, module_name, sub_packages), encoding="utf-8" 215 | ) 216 | 217 | foopy = git_repo / f"{module_name}.py" 218 | foopy.write_text(PY_MODULE_TEMPLATE, encoding="utf-8") 219 | 220 | license = git_repo / "LICENSE" 221 | license.write_text(LICENSE_TEMPLATE, encoding="utf-8") 222 | 223 | here = Path(__file__).parent 224 | text = here.parent.joinpath(".pre-commit-config.yaml").read_text(encoding="utf-8") 225 | 226 | # Remove sp-repo-review and don't check yaml files. 227 | yaml = YAML(typ="safe") 228 | table = yaml.load(text) 229 | for item in list(table["repos"]): 230 | if item["repo"] == "https://github.com/scientific-python/cookie": 231 | table["repos"].remove(item) 232 | 233 | pre_commit = git_repo / ".pre-commit-config.yaml" 234 | with open(str(pre_commit), "w") as fid: 235 | yaml.dump(table, fid) 236 | 237 | readme = git_repo / "README.md" 238 | readme.write_text(README_TEMPLATE, encoding="utf-8") 239 | 240 | sub_packages = [] 241 | if multi: 242 | packages = [{"abs_path": git_repo, "rel_path": "."}] 243 | for i in range(2): 244 | sub_package = Path(f"sub_package{i}") 245 | sub_packages.append(str(sub_package)) 246 | packages.append( 247 | { 248 | "abs_path": git_repo / sub_package, 249 | "rel_path": sub_package, 250 | } 251 | ) 252 | sub_package.mkdir() 253 | package_name = f"foo{i}" 254 | module_name = f"foo{i}bar" if not_matching_name else None 255 | write_files( 256 | git_repo / sub_package, 257 | package_name=package_name, 258 | module_name=module_name, 259 | ) 260 | run(f"git add {sub_package}") 261 | run(f'git commit -m "initial python {sub_package}"') 262 | 263 | package_name = "foo" 264 | module_name = "foobar" if not_matching_name else None 265 | write_files( 266 | git_repo, 267 | sub_packages=sub_packages, 268 | package_name=package_name, 269 | module_name=module_name, 270 | ) 271 | run("git add .") 272 | run('git commit -m "initial python package"') 273 | 274 | run("git checkout foo") 275 | run("git pull origin bar", quiet=True) 276 | run("git checkout bar") 277 | 278 | if multi: 279 | return packages 280 | return git_repo 281 | 282 | 283 | def create_draft_release(ref="bar", files=None): 284 | gh = GhApi("foo", "bar") 285 | release = gh.create_release(ref, "bar", ref, "body", True, True) 286 | if files: 287 | with tempfile.TemporaryDirectory() as td: 288 | metadata_file = os.path.join(td, "metadata.json") 289 | with open(metadata_file, "w") as fid: 290 | fid.write("{}") 291 | gh.upload_file(release, metadata_file) 292 | release = util.release_for_url(gh, release.url) 293 | util.upload_assets(gh, files, release, "foo") 294 | return release 295 | --------------------------------------------------------------------------------