From c902c2b392f2330e4a24a9fd5be868cc5aae2c55 Mon Sep 17 00:00:00 2001 From: Jacob Yundt Date: Sun, 23 Aug 2026 08:26:25 -0400 Subject: [PATCH 1/2] Use trusted publishing for releases Signed-off-by: Jacob Yundt --- .github/workflows/main.yml | 185 ++++++++++++++++++++----------------- README.rst | 17 +++- 2 files changed, 110 insertions(+), 92 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a407fcd..c672854 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,92 +1,103 @@ name: Release and Publish + on: - workflow_dispatch: - inputs: - version: - description: 'Version number' - required: true - changes_1: - description: 'Change entry' - required: true - changes_2: - description: 'Change entry' - required: false - changes_3: - description: 'Change entry' - required: false - changes_4: - description: 'Change entry' - required: false - changes_5: - description: 'Change entry' - required: false - changes_6: - description: 'Change entry' - required: false - changes_7: - description: 'Change entry' - required: false - changes_8: - description: 'Change entry' - required: false + release: + types: + - published + +permissions: {} + jobs: - release_build: - name: Build the release + build: + name: Build release distributions runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v2 - with: - token: ${{secrets.GITHUB_TOKEN}} - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build the changelog text - run: | - echo 'CHANGES<> $GITHUB_ENV - echo "## [${{github.event.inputs.version}}] - $(date +'%Y-%m-%d')" >> $GITHUB_ENV - echo "- ${{github.event.inputs.changes_1}}" >> $GITHUB_ENV - if [[ -n "${{github.event.inputs.changes_2}}" ]]; then echo "- ${{github.event.inputs.changes_2}}" >> $GITHUB_ENV; fi - if [[ -n "${{github.event.inputs.changes_3}}" ]]; then echo "- ${{github.event.inputs.changes_3}}" >> $GITHUB_ENV; fi - if [[ -n "${{github.event.inputs.changes_4}}" ]]; then echo "- ${{github.event.inputs.changes_4}}" >> $GITHUB_ENV; fi - if [[ -n "${{github.event.inputs.changes_5}}" ]]; then echo "- ${{github.event.inputs.changes_5}}" >> $GITHUB_ENV; fi - if [[ -n "${{github.event.inputs.changes_6}}" ]]; then echo "- ${{github.event.inputs.changes_6}}" >> $GITHUB_ENV; fi - if [[ -n "${{github.event.inputs.changes_7}}" ]]; then echo "- ${{github.event.inputs.changes_7}}" >> $GITHUB_ENV; fi - if [[ -n "${{github.event.inputs.changes_8}}" ]]; then echo "- ${{github.event.inputs.changes_8}}" >> $GITHUB_ENV; fi - echo "" >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - - name: Update version numbers - run: | - sed -i -E 's/ version=.+,/ version='\'${{github.event.inputs.version}}\'',/' setup.py - sed -i -E 's/__version__ = .+/__version__ = "'${{github.event.inputs.version}}'"/' src/redfish/__init__.py - - name: Update the changelog - run: | - ex CHANGELOG.md <" - git add CHANGELOG.md setup.py src/redfish/__init__.py - git commit -s -m "${{github.event.inputs.version}} versioning" - git push origin main - - name: Make the release - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - run: | - gh release create ${{github.event.inputs.version}} -t ${{github.event.inputs.version}} -n "Changes since last release:"$'\n\n'"$CHANGES" - - name: Build the distribution - run: | - python setup.py sdist bdist_wheel - - name: Upload to pypi - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} + - name: Check out the release tag + # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python + # v7.0.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.x" + + - name: Verify release metadata + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python - <<'PY' + import os + import re + from pathlib import Path + + release_tag = os.environ["RELEASE_TAG"] + version_pattern = r"[0-9]+(?:\.[0-9]+){2}[a-zA-Z0-9.-]*" + if not re.fullmatch(version_pattern, release_tag): + raise SystemExit(f"Invalid release tag: {release_tag}") + + setup_version = re.search( + r"^[ ]+version='([^']+)'", + Path("setup.py").read_text(), + re.MULTILINE, + ) + package_version = re.search( + r'^__version__ = "([^"]+)"', + Path("src/redfish/__init__.py").read_text(), + re.MULTILINE, + ) + if setup_version is None or package_version is None: + raise SystemExit("Unable to read package versions") + if setup_version.group(1) != release_tag: + raise SystemExit( + "setup.py version does not match the release tag" + ) + if package_version.group(1) != release_tag: + raise SystemExit( + "redfish.__version__ does not match the release tag" + ) + if f"## [{release_tag}]" not in Path("CHANGELOG.md").read_text(): + raise SystemExit("CHANGELOG.md does not contain the release tag") + PY + + - name: Build release distributions + run: | + python -m pip install build twine + python -m build + python -m twine check dist/* + + - name: Store release distributions + # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: release-distributions + path: dist/ + if-no-files-found: error + + publish: + name: Publish release distributions to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/redfish + permissions: + id-token: write + steps: + - name: Retrieve release distributions + # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: release-distributions + path: dist/ + + - name: Publish release distributions to PyPI + # v1.14.2 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 + with: + attestations: true diff --git a/README.rst b/README.rst index ca4eeef..46860c6 100644 --- a/README.rst +++ b/README.rst @@ -311,11 +311,18 @@ For example: Release Process --------------- -1. Go to the "Actions" page -2. Select the "Release and Publish" workflow -3. Click "Run workflow" -4. Fill out the form -5. Click "Run workflow" +Before the first release, configure a PyPI `Trusted Publisher +`_ for the +``DMTF/python-redfish-library`` repository, the ``main.yml`` workflow, and the +``pypi`` environment. + +1. Update ``CHANGELOG.md``, ``setup.py``, and ``src/redfish/__init__.py`` with + the new version in a pull request. +2. Merge the pull request. +3. Create a GitHub release from the merged commit with the version as its tag. +4. Publish the GitHub release. The release workflow builds the tagged sources, + verifies their version metadata, and publishes the distributions to PyPI + using Trusted Publishing. Copyright and License --------------------- From ad19d8c16e8b41d946e504e4b6c3631156152525 Mon Sep 17 00:00:00 2001 From: Jacob Yundt Date: Tue, 25 Aug 2026 11:13:26 -0400 Subject: [PATCH 2/2] Preserve push-button release preparation Signed-off-by: Jacob Yundt --- .github/workflows/main.yml | 40 ++++- .github/workflows/prepare-release.yml | 210 ++++++++++++++++++++++++++ README.rst | 21 +-- 3 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/prepare-release.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c672854..e486b1c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,12 +1,14 @@ -name: Release and Publish +name: Publish to PyPI on: - release: - types: - - published + workflow_dispatch: permissions: {} +concurrency: + group: publish-to-pypi + cancel-in-progress: false + jobs: build: name: Build release distributions @@ -14,12 +16,36 @@ jobs: permissions: contents: read steps: + - name: Verify the workflow is running at a tag + env: + RELEASE_REF_TYPE: ${{ github.ref_type }} + run: | + if [[ "$RELEASE_REF_TYPE" != "tag" ]]; then + echo "This workflow must be dispatched at a release tag" + exit 1 + fi + + - name: Verify the GitHub release is published + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release view "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --json isDraft \ + --jq .isDraft \ + > "$RUNNER_TEMP/release-draft.txt" + read -r release_is_draft < "$RUNNER_TEMP/release-draft.txt" + if [[ "$release_is_draft" != "false" ]]; then + echo "The GitHub release for $RELEASE_TAG is still a draft" + exit 1 + fi + - name: Check out the release tag # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - ref: ${{ github.event.release.tag_name }} - name: Set up Python # v7.0.0 @@ -29,7 +55,7 @@ jobs: - name: Verify release metadata env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.ref_name }} run: | python - <<'PY' import os @@ -87,7 +113,7 @@ jobs: name: pypi url: https://pypi.org/p/redfish permissions: - id-token: write + id-token: write # Exchange the GitHub OIDC token for PyPI credentials. steps: - name: Retrieve release distributions # v8.0.1 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..1fc08b8 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,210 @@ +name: Update Version and Create Release + +on: + workflow_dispatch: + inputs: + version: + description: Version number + required: true + type: string + changes_1: + description: Change entry + required: true + type: string + changes_2: + description: Change entry + required: false + type: string + changes_3: + description: Change entry + required: false + type: string + changes_4: + description: Change entry + required: false + type: string + changes_5: + description: Change entry + required: false + type: string + changes_6: + description: Change entry + required: false + type: string + changes_7: + description: Change entry + required: false + type: string + changes_8: + description: Change entry + required: false + type: string + +permissions: {} + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + name: Update release metadata and create the release + runs-on: ubuntu-latest + permissions: + contents: write # Commit metadata and create the tagged release. + steps: + - name: Verify the workflow is running from the default branch + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "This workflow must be run from the main branch" + exit 1 + fi + + - name: Check out the default branch + # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + ref: main + + - name: Set up Python + # v7.0.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.x" + + - name: Update release metadata + env: + CHANGE_1: ${{ inputs.changes_1 }} + CHANGE_2: ${{ inputs.changes_2 }} + CHANGE_3: ${{ inputs.changes_3 }} + CHANGE_4: ${{ inputs.changes_4 }} + CHANGE_5: ${{ inputs.changes_5 }} + CHANGE_6: ${{ inputs.changes_6 }} + CHANGE_7: ${{ inputs.changes_7 }} + CHANGE_8: ${{ inputs.changes_8 }} + VERSION: ${{ inputs.version }} + run: | + python - <<'PY' + import datetime + import os + import re + from pathlib import Path + + version = os.environ["VERSION"] + version_pattern = r"[0-9]+(?:\.[0-9]+){2}[a-zA-Z0-9.-]*" + if not re.fullmatch(version_pattern, version): + raise SystemExit(f"Invalid version: {version}") + + changes = [] + for index in range(1, 9): + change = os.environ[f"CHANGE_{index}"].strip() + if "\n" in change or "\r" in change: + raise SystemExit("Change entries must contain one line") + if change: + changes.append(change) + if not changes: + raise SystemExit("At least one change entry is required") + + version_bytes = version.encode("ascii") + + def replace_version(path, pattern): + content = path.read_bytes() + updated, count = re.subn( + pattern, + lambda match: match.group(1) + version_bytes + match.group(2), + content, + flags=re.MULTILINE, + ) + if count != 1: + raise SystemExit(f"Unable to update version in {path}") + path.write_bytes(updated) + + replace_version( + Path("setup.py"), + rb"^([ \t]+version=')[^']+(',\r?)$", + ) + replace_version( + Path("src/redfish/__init__.py"), + rb'^(__version__ = ")[^"]+("\r?)$', + ) + + changelog_path = Path("CHANGELOG.md") + changelog = changelog_path.read_bytes() + if re.search( + rb"^## \[" + re.escape(version_bytes) + rb"\]", + changelog, + re.MULTILINE, + ): + raise SystemExit(f"CHANGELOG.md already contains {version}") + + if b"# Change Log\r\n\r\n" in changelog: + newline = b"\r\n" + elif b"# Change Log\n\n" in changelog: + newline = b"\n" + else: + raise SystemExit("Unable to find the CHANGELOG.md heading") + + heading = b"# Change Log" + newline * 2 + date = datetime.datetime.now(datetime.UTC).date().isoformat() + entry_lines = [f"## [{version}] - {date}"] + entry_lines.extend(f"- {change}" for change in changes) + entry = newline.join(line.encode() for line in entry_lines) + newline * 2 + changelog_path.write_bytes( + changelog.replace(heading, heading + entry, 1) + ) + + notes = "Changes since last release:\n\n" + notes += "\n".join(f"- {change}" for change in changes) + notes += "\n" + Path(os.environ["RUNNER_TEMP"], "release-notes.md").write_text(notes) + PY + + - name: Commit release metadata + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md setup.py src/redfish/__init__.py + if git diff --cached --quiet; then + echo "No release metadata changes were produced" + exit 1 + fi + git commit --signoff --message "$VERSION versioning" + gh auth setup-git + git push origin HEAD:main + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + git rev-parse HEAD > "$RUNNER_TEMP/release-commit.txt" + read -r release_commit < "$RUNNER_TEMP/release-commit.txt" + gh release create "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$release_commit" \ + --title "$VERSION" \ + --notes-file "$RUNNER_TEMP/release-notes.md" + + queue-publication: + name: Queue the tagged PyPI publication + needs: prepare + runs-on: ubuntu-latest + permissions: + actions: write # Dispatch the publishing workflow at the release tag. + contents: read + steps: + - name: Start the publishing workflow at the release tag + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + gh workflow run main.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$VERSION" diff --git a/README.rst b/README.rst index 46860c6..6ce7db7 100644 --- a/README.rst +++ b/README.rst @@ -314,15 +314,18 @@ Release Process Before the first release, configure a PyPI `Trusted Publisher `_ for the ``DMTF/python-redfish-library`` repository, the ``main.yml`` workflow, and the -``pypi`` environment. - -1. Update ``CHANGELOG.md``, ``setup.py``, and ``src/redfish/__init__.py`` with - the new version in a pull request. -2. Merge the pull request. -3. Create a GitHub release from the merged commit with the version as its tag. -4. Publish the GitHub release. The release workflow builds the tagged sources, - verifies their version metadata, and publishes the distributions to PyPI - using Trusted Publishing. +``pypi`` environment. Configure the ``pypi`` GitHub environment with a required +reviewer so that publishing to PyPI requires explicit approval. + +1. Go to the "Actions" page. +2. Select the "Update Version and Create Release" workflow. +3. Click "Run workflow", fill out the version and change entries, and run the + workflow. It updates the version and changelog, pushes the changes to + ``main``, and creates the tagged GitHub release. +4. The workflow queues "Publish to PyPI" at the release tag. Review and approve + its pending deployment to the ``pypi`` environment. +5. The publishing workflow verifies the tagged release metadata, builds the + distributions, and publishes them to PyPI using Trusted Publishing. Copyright and License ---------------------