Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 110 additions & 21 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,131 @@
name: deploy

on:
push:
tags:
- "*"
release:
types: [published]

# Set permissions at the job level.
permissions: {}

jobs:
package:
name: Build & inspect our package.
verify:
name: Verify release PR status
if: github.repository == 'pytest-dev/pluggy'
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
checks: read
pull-requests: read
outputs:
pr-number: ${{ steps.verify.outputs.pr-number }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Find release PR and verify CI status
id: verify
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
env:
VERSION: ${{ github.ref_name }}
with:
fetch-depth: 0
persist-credentials: false
- uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0
with:
attest-build-provenance-github: 'true'
script: |
const { VERSION } = process.env;
const branch = `release-${VERSION}`;

// Find the open release PR.
const { data: prs } = await github.rest.pulls.list({
...context.repo,
head: `${context.repo.owner}:${branch}`,
state: 'open',
});
if (prs.length === 0) {
core.setFailed(`No open PR found for branch ${branch}`);
return;
}
const prNumber = prs[0].number;
core.setOutput('pr-number', prNumber);
core.info(`Found PR #${prNumber} for ${branch}`);

// Verify all required checks passed on the PR head commit.
const { data: checks } = await github.rest.checks.listForRef({
...context.repo,
ref: prs[0].head.sha,
});
const failed = checks.check_runs.filter(
cr => cr.conclusion !== 'success' && cr.conclusion !== 'skipped' && cr.conclusion !== null
);
if (failed.length > 0) {
for (const cr of failed) {
core.error(`${cr.name}: ${cr.conclusion}`);
}
core.setFailed(`PR #${prNumber} has ${failed.length} failing check(s) — refusing to deploy.`);
return;
}
core.info('All PR checks passed.');

deploy:
needs: [package]
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pluggy'
name: Publish to PyPI
needs: [verify]
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: write
steps:
- name: Download built packages from the check-package job.
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
mkdir dist
gh release download "${GITHUB_REF_NAME}" --dir dist
ls -la dist/

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
name: Packages
path: dist
- name: Publish package
path: repo
sparse-checkout: |
.github
persist-credentials: false

- name: Generate build provenance attestations
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
with:
subject-path: "dist/*"

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
attestations: true

merge:
name: Merge release PR
needs: [verify, deploy]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Merge PR and delete release branch
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
env:
PR_NUMBER: ${{ needs.verify.outputs.pr-number }}
VERSION: ${{ github.ref_name }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const branch = `release-${process.env.VERSION}`;

await github.rest.pulls.merge({
...context.repo,
pull_number: prNumber,
merge_method: 'merge',
commit_title: `Merge release ${process.env.VERSION} (#${prNumber})`,
});
core.info(`Merged PR #${prNumber}`);

try {
await github.rest.git.deleteRef({
...context.repo,
ref: `heads/${branch}`,
});
core.info(`Deleted branch ${branch}`);
} catch (e) {
if (e.status !== 422) throw e;
core.info(`Branch ${branch} already deleted.`);
}
4 changes: 3 additions & 1 deletion .github/workflows/downstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ jobs:
timeout-minutes: 120
if: >-
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request' && contains(github.head_ref, 'downstream'))
|| (github.event_name == 'pull_request'
&& (startsWith(github.head_ref, 'release-')
|| contains(github.head_ref, 'downstream')))
strategy:
fail-fast: false
matrix:
Expand Down
165 changes: 165 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
name: prepare-release

on:
push:
branches:
- main

permissions: {}

jobs:
prepare:
name: Prepare release PR
runs-on: ubuntu-latest
# Only run in the upstream repo, not forks.
if: github.repository == 'pytest-dev/pluggy'
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"

- name: Install dependencies
run: pip install "setuptools-scm[toml]>=10.0" towncrier

- name: Compute next version
id: version
run: |
VERSION=$(python -m setuptools_scm --strip-dev) || exit 0
if [ -z "$VERSION" ]; then exit 0; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "branch=release-$VERSION" >> "$GITHUB_OUTPUT"
echo "Computed version: $VERSION"

- name: Check if release is already published or branch is up-to-date
if: steps.version.outputs.version
id: check
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
env:
VERSION: ${{ steps.version.outputs.version }}
BRANCH: release-${{ steps.version.outputs.version }}
PUSH_SHA: ${{ github.sha }}
with:
script: |
const { VERSION, BRANCH, PUSH_SHA } = process.env;

// A published (non-draft) release means the version is locked in.
try {
const release = await github.rest.repos.getReleaseByTag({
...context.repo,
tag: VERSION,
});
if (!release.data.draft) {
core.notice(`Release ${VERSION} is already published — nothing to do.`);
core.setOutput('skip', 'true');
return;
}
} catch (e) {
if (e.status !== 404) throw e;
}

// If the release branch already exists and was built from this
// exact main commit, there is nothing to update.
try {
const commits = await github.rest.repos.listCommits({
...context.repo,
sha: BRANCH,
per_page: 2,
});
if (commits.data.length >= 2 && commits.data[1].sha === PUSH_SHA) {
core.info(`Release branch ${BRANCH} already up-to-date with main.`);
core.setOutput('skip', 'true');
return;
}
} catch (e) {
if (e.status !== 404 && e.status !== 409) throw e;
}

- name: Create release branch
if: steps.version.outputs.version && steps.check.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
BRANCH="${{ steps.version.outputs.branch }}"

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git checkout -B "$BRANCH"

towncrier build --yes --version "$VERSION"

git add -A
git commit -m "Preparing release $VERSION"
git push --force origin "$BRANCH"

- name: Create or update pull request
if: steps.version.outputs.version && steps.check.outputs.skip != 'true'
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
env:
VERSION: ${{ steps.version.outputs.version }}
BRANCH: release-${{ steps.version.outputs.version }}
with:
script: |
const { VERSION, BRANCH } = process.env;
const body = [
'Automated release PR created by CI.',
'',
'## Checklist',
'',
'- [ ] Review the generated CHANGELOG.rst',
'- [ ] Ensure all CI checks pass',
'- [ ] When ready, **publish the draft GitHub release** to trigger PyPI deployment and merge',
].join('\n');

// Look for an existing PR from this release branch.
const { data: prs } = await github.rest.pulls.list({
...context.repo,
head: `${context.repo.owner}:${BRANCH}`,
state: 'open',
});

if (prs.length > 0) {
await github.rest.pulls.update({
...context.repo,
pull_number: prs[0].number,
title: `Release ${VERSION}`,
body,
});
core.info(`Updated existing PR #${prs[0].number}`);
} else {
// Close any stale release PRs for a different version.
const { data: allPrs } = await github.rest.pulls.list({
...context.repo,
state: 'open',
});
for (const pr of allPrs) {
if (pr.head.ref.startsWith('release-') && pr.head.ref !== BRANCH) {
await github.rest.pulls.update({
...context.repo,
pull_number: pr.number,
state: 'closed',
});
await github.rest.issues.createComment({
...context.repo,
issue_number: pr.number,
body: `Superseded by release ${VERSION}.`,
});
core.info(`Closed stale PR #${pr.number} (${pr.head.ref})`);
}
}

const { data: newPr } = await github.rest.pulls.create({
...context.repo,
head: BRANCH,
base: 'main',
title: `Release ${VERSION}`,
body,
});
core.info(`Created new PR #${newPr.number} for ${BRANCH}`);
}
Loading
Loading