Skip to content

Commit 39a2cd5

Browse files
authored
ci(release): adopt the verified release flow (#99)
* ci(release): adopt the verified release flow The old arrangement created a draft, a human clicked Publish, and only then did the publish workflows run -- so everything that verified a release ran after it was already public. The release is now created as a prerelease and promoted only once everything that can fail has succeeded; the confirmation step is an environment approval on the job that tags, reached after lint and build are already green. The version no longer has to be typed: it is auto-detected from Conventional Commits, and passing one that disagrees needs `force`. `prerelease: rc` cuts a release candidate, verified exactly like a release but never promoted to latest. See RELEASING.md in reqstool/.github for the whole flow. build.yml gains `ref` so the flow can re-run it against the tag it just created -- that build is what gives the artifacts their version, since hatch-vcs reads it from git. It also gains `artifact-name`, because the flow builds twice in one run and upload-artifact rejects a duplicate name. Its checkout now fetches tags: with hatch-vcs a shallow clone computes the wrong version rather than failing. publish_pypi_test.yml is renamed publish-dev-to-testpypi.yml and calls the shared workflow. Behaviour is unchanged -- every push to main still lands on Test PyPI as a .devN build. Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> * fix(release): pass version-format to the tag job common-release-tag.yml now validates the version and the ref itself rather than trusting that prepare validated the same values (CodeQL flagged the privileged checkout on an unvalidated ref in reqstool/.github#66). Validating the version needs to know which format to validate against. Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> --------- Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent 37d0284 commit 39a2cd5

6 files changed

Lines changed: 192 additions & 95 deletions

File tree

.github/workflows/build.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
name: Build PyPI
22
on:
3-
workflow_call:
3+
workflow_call:
4+
inputs:
5+
ref:
6+
description: "Branch, tag or SHA to build. Empty = the caller's ref."
7+
required: false
8+
type: string
9+
default: ""
10+
artifact-name:
11+
description: >
12+
Name to upload the build output under. The release flow runs this
13+
workflow twice in one run -- once on the branch, once on the tag -- and
14+
upload-artifact rejects a duplicate name.
15+
required: false
16+
type: string
17+
default: "dist"
418
workflow_dispatch:
519
push:
620
branches:
@@ -20,10 +34,15 @@ jobs:
2034
needs: linting
2135
runs-on: ubuntu-latest
2236
steps:
37+
# Full history and tags: hatch-vcs derives the version from git state, so a
38+
# shallow clone would build the wrong number rather than fail.
2339
- name: Check out source repository
2440
uses: actions/checkout@v7
2541
with:
26-
fetch-depth: 0 #full history
42+
persist-credentials: false
43+
fetch-depth: 0
44+
fetch-tags: true
45+
ref: ${{ inputs.ref || github.ref }}
2746
- name: Set up Python
2847
uses: actions/setup-python@v7
2948
with:
@@ -40,5 +59,5 @@ jobs:
4059
- name: Upload Artifacts
4160
uses: actions/upload-artifact@v7
4261
with:
43-
name: dist
62+
name: ${{ inputs.artifact-name || 'dist' }}
4463
path: dist/

.github/workflows/check_release.yml

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Publish dev build to Test PyPI
2+
3+
# The continuous dev feed: every push to main lands on Test PyPI as a `.devN`
4+
# build. Separate from the release flow, which publishes the tagged version to
5+
# Test PyPI and then PyPI -- both use skip-existing, so they cannot collide.
6+
7+
on:
8+
workflow_dispatch:
9+
push:
10+
branches:
11+
- main
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
build:
18+
name: Reuse build
19+
uses: ./.github/workflows/build.yml
20+
permissions:
21+
contents: read
22+
23+
publish-to-test-pypi:
24+
needs: build
25+
uses: reqstool/.github/.github/workflows/python-publish-to-pypi.yml@main
26+
permissions:
27+
id-token: write
28+
with:
29+
target: testpypi

.github/workflows/publish_pypi_prod.yml

Lines changed: 0 additions & 36 deletions
This file was deleted.

.github/workflows/publish_pypi_test.yml

Lines changed: 0 additions & 35 deletions
This file was deleted.

.github/workflows/release.yml

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
name: Release
2+
3+
# The whole release, start to finish. See RELEASING.md in reqstool/.github for
4+
# what each step does, and for why the release is created as a prerelease rather
5+
# than a draft.
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
version:
11+
description: "Version to release (PEP 440, no v prefix), e.g. 0.2.0. Leave empty to auto-detect from Conventional Commits."
12+
required: false
13+
type: string
14+
prerelease:
15+
description: "Publish as a release candidate instead of a release: verified like any release, but never promoted to latest. The number is chosen for you (0.2.0 -> 0.2.0rc1, then the next)."
16+
required: false
17+
type: choice
18+
options: [none, rc, b, a]
19+
default: none
20+
ref:
21+
description: "Branch to release from. Leave empty for the branch this workflow was dispatched on."
22+
required: false
23+
type: string
24+
force:
25+
description: "Allow a version that disagrees with the auto-detected one."
26+
required: false
27+
type: boolean
28+
default: false
29+
dry-run:
30+
description: "Validate and preview only -- nothing tagged, nothing published."
31+
required: false
32+
type: boolean
33+
default: true
34+
35+
concurrency:
36+
group: release
37+
cancel-in-progress: false
38+
39+
permissions:
40+
contents: read
41+
42+
jobs:
43+
prepare:
44+
uses: reqstool/.github/.github/workflows/common-release-prepare.yml@main
45+
permissions:
46+
contents: read
47+
with:
48+
version-format: pep440
49+
version: ${{ inputs.version }}
50+
prerelease: ${{ inputs.prerelease }}
51+
ref: ${{ inputs.ref }}
52+
force: ${{ inputs.force }}
53+
dry-run: ${{ inputs.dry-run }}
54+
55+
# The same checks that guard main, called rather than reimplemented, and run
56+
# before the approval gate so the reviewer approves something already green
57+
# rather than a version string.
58+
checks:
59+
needs: prepare
60+
if: ${{ !inputs.dry-run }}
61+
uses: ./.github/workflows/build.yml
62+
permissions:
63+
contents: read
64+
65+
# THE APPROVAL GATE -- bound to the `stable` environment, so it sits pending
66+
# until a required reviewer approves it on the run page.
67+
tag:
68+
needs: [prepare, checks]
69+
if: ${{ !inputs.dry-run }}
70+
uses: reqstool/.github/.github/workflows/common-release-tag.yml@main
71+
permissions:
72+
contents: write
73+
with:
74+
version: ${{ needs.prepare.outputs.version }}
75+
version-format: pep440
76+
ref: ${{ inputs.ref }}
77+
78+
# Rebuilt from the tag, which is what gives the artifacts their version:
79+
# hatch-vcs reads it from git rather than from a version string in the tree.
80+
build-tagged:
81+
needs: [prepare, tag]
82+
uses: ./.github/workflows/build.yml
83+
permissions:
84+
contents: read
85+
with:
86+
ref: ${{ needs.prepare.outputs.version }}
87+
artifact-name: dist-tagged
88+
89+
assets:
90+
needs: [prepare, build-tagged]
91+
uses: reqstool/.github/.github/workflows/common-release-assets.yml@main
92+
permissions:
93+
contents: write
94+
with:
95+
version: ${{ needs.prepare.outputs.version }}
96+
artifact: dist-tagged
97+
98+
publish-to-testpypi:
99+
needs: [prepare, assets]
100+
uses: reqstool/.github/.github/workflows/python-publish-to-pypi.yml@main
101+
permissions:
102+
id-token: write
103+
with:
104+
target: testpypi
105+
artifact: dist-tagged
106+
107+
# PyPI is the only step here that cannot be undone: a version can be yanked but
108+
# never replaced. A release candidate stops at Test PyPI -- pip needs --pre to
109+
# see a prerelease anyway.
110+
publish-to-pypi:
111+
needs: [prepare, publish-to-testpypi]
112+
if: ${{ needs.prepare.outputs.prerelease != 'true' }}
113+
uses: reqstool/.github/.github/workflows/python-publish-to-pypi.yml@main
114+
permissions:
115+
id-token: write
116+
with:
117+
target: pypi
118+
artifact: dist-tagged
119+
120+
# Last, deliberately. Everything above can fail, and until this runs nothing
121+
# resolving "the latest release" can see what was built -- the release is still
122+
# a prerelease. Promotion itself is one API call against a release that already
123+
# has its artifacts.
124+
#
125+
# The guard is `no job failed`, not the default `every job succeeded`: a release
126+
# candidate deliberately skips the publish jobs that a real release runs, and a
127+
# skipped dependency would otherwise cascade and skip this too -- leaving the
128+
# candidate unpromoted, which is right, and every *real* release unpromoted the
129+
# moment any optional job is skipped, which is not.
130+
#
131+
# `!inputs.dry-run` has to be spelled out for the same reason: on a dry run
132+
# every job above is skipped, and "nothing failed" would otherwise be true.
133+
promote:
134+
needs: [prepare, assets, publish-to-testpypi, publish-to-pypi]
135+
if: ${{ !inputs.dry-run && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
136+
uses: reqstool/.github/.github/workflows/common-release-promote.yml@main
137+
permissions:
138+
contents: write
139+
with:
140+
version: ${{ needs.prepare.outputs.version }}
141+
prerelease: ${{ needs.prepare.outputs.prerelease == 'true' }}

0 commit comments

Comments
 (0)