forked from ChelseaKR/habitable
-
Notifications
You must be signed in to change notification settings - Fork 0
263 lines (248 loc) · 11.4 KB
/
Copy pathrelease.yml
File metadata and controls
263 lines (248 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# SPDX-License-Identifier: AGPL-3.0-or-later
name: release
on:
workflow_dispatch:
inputs:
tag:
description: "Existing signed stable SemVer tag (vX.Y.Z)"
required: true
type: string
permissions:
contents: read
# A tag push and a manual re-run of the same tag must never race each other into
# two concurrent publishes of the same release; queue them instead of cancelling
# (cancelling a half-finished publish is worse than waiting).
concurrency:
group: release
cancel-in-progress: false
jobs:
verify-build:
name: verify signed tag · build exact artifacts
runs-on: ubuntu-latest
outputs:
release_commit: ${{ steps.tag.outputs.commit }}
release_tag: ${{ steps.tag.outputs.tag }}
tag_object_sha: ${{ steps.tag.outputs.tag_object_sha }}
permissions:
contents: read
steps:
# P1-3 (SEC-04): audit-mode egress monitoring; see ci.yml's Harden Runner
# step for the telemetry trade-off note. Flip to `block` + an explicit
# allowlist once a few real release runs show the actual endpoint set
# (PyPI/uv index, GitHub API, Sigstore) — don't guess the allowlist blind.
- name: Harden Runner (audit mode)
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Checkout (full history + tags — needed to verify the tag signature)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Resolve and check out the exact release tag commit
id: tag
env:
# Bind the (potentially attacker-influenceable, on a workflow_dispatch
# input) values to an env var rather than expanding `${{ }}` directly
# into the shell command — avoids template-injection (zizmor High).
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${INPUT_TAG}"
test "${GITHUB_REF}" = refs/heads/main
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
test "$(git rev-parse origin/main)" = "${GITHUB_SHA}"
if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::Release tag '$TAG' is not a stable vX.Y.Z tag."
exit 1
fi
if [ "$(git cat-file -t "refs/tags/${TAG}")" != tag ]; then
echo "::error::Release tag '$TAG' must be an annotated tag object."
exit 1
fi
if ! TAG_COMMIT="$(git rev-parse --verify --end-of-options "${TAG}^{commit}")"; then
echo "::error::Release tag '$TAG' does not resolve to a commit."
exit 1
fi
if ! git merge-base --is-ancestor "$TAG_COMMIT" origin/main; then
echo "::error::Release tag '$TAG' is not on the reviewed default-branch history."
exit 1
fi
TAG_OBJECT_SHA="$(git rev-parse "refs/tags/${TAG}")"
git checkout --detach "$TAG_COMMIT"
if [ "$(git rev-parse HEAD)" != "$TAG_COMMIT" ]; then
echo "::error::Checked-out HEAD does not match release tag '$TAG'."
exit 1
fi
{
echo "tag=$TAG"
echo "commit=$TAG_COMMIT"
echo "tag_object_sha=$TAG_OBJECT_SHA"
} >> "$GITHUB_OUTPUT"
echo "Building exact tag commit $TAG_COMMIT."
# --- REL-02/03/08: release identity guard ---------------------------------
# An evidence tool's artifact identity is part of its safety case: a tag whose
# version doesn't match pyproject, or that isn't signed, must never reach a
# published release. This fails BEFORE any build step runs.
#
# Uses SSH-format signature verification (git's `gpg.format=ssh`): simpler to
# bootstrap in CI than importing a GPG key, and git has supported it natively
# since 2.34. `.github/allowed_signers` must contain the maintainer's real
# public signing key for this to ever succeed — see that file's header.
- name: Configure tag-signature verification (SSH format)
run: |
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile "${GITHUB_WORKSPACE}/.github/allowed_signers"
- name: "Guard: tag must be signed"
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
if ! git verify-tag -- "$TAG" 2>/tmp/tag-verify.log; then
echo "::error::Tag '$TAG' is not a valid signed tag (git verify-tag failed)."
echo "::error::habitable requires signed release tags (REL-08). See docs/releasing.md."
cat /tmp/tag-verify.log
exit 1
fi
- name: "Guard: tag version must match pyproject.toml"
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
TAG_VERSION="${TAG#v}"
PYPROJECT_VERSION="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')"
if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
echo "::error::Tag '$TAG' (version $TAG_VERSION) does not match pyproject.toml version '$PYPROJECT_VERSION'."
exit 1
fi
echo "Tag version $TAG_VERSION matches pyproject.toml."
# --- REL-14/15: release re-runs the full merge gate before it ships --------
- name: Install uv (pinned)
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.11.19"
enable-cache: false # this job runs once per release; a stale cache is not worth the risk
- name: Lockfile drift (CQ-09)
# Must precede `uv sync` and any `uv run`: a bare `uv run` silently relocks,
# repairing the very drift a later check would look for. The sync below asks
# for `--locked`, not `--frozen`; `--frozen` installs from uv.lock without
# reading pyproject.toml, so it cannot see the two disagree and exits 0 on a
# drifted lock.
run: uv lock --check
- name: Sync environment (Python 3.14, locked, with dev tools)
run: uv sync --locked
- name: Run the full gate at the tagged commit (ruff + mypy --strict + pytest + coverage + i18n)
run: make verify
- name: Set up Buildx with an OCI-export-capable builder
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build wheel + sdist, verifying the build is reproducible
run: make repro
- name: Build relay OCI archives twice, verifying byte-identical output
run: make relay-repro
- name: Smoke-test the installed wheel and packaged app
run: |
uv venv --python 3.14 /tmp/habitable-wheel-smoke
uv pip install --python /tmp/habitable-wheel-smoke/bin/python dist/*.whl
/tmp/habitable-wheel-smoke/bin/python scripts/smoke_test_installed_wheel.py
- name: Generate a runtime SBOM (CycloneDX)
run: |
uv sync --locked --no-dev
uvx cyclonedx-py environment .venv > dist/sbom.cdx.json
- name: Hand the exact verified distributions to the PyPI job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pypi-distributions
path: |
dist/*.whl
dist/*.tar.gz
if-no-files-found: error
retention-days: 7
- name: Hand the exact verified release assets to the checkout-free publisher
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-assets
path: dist/*
if-no-files-found: error
retention-days: 7
publish-release:
name: attest · recheck tag object · publish without checkout
needs: verify-build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Harden Runner (audit mode)
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Download only the verified release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: dist
- name: Recheck the immutable tag object before granting publication
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.verify-build.outputs.release_tag }}
TAG_OBJECT_SHA: ${{ needs.verify-build.outputs.tag_object_sha }}
run: |
set -euo pipefail
LIVE_TAG_OBJECT="$(
gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha
)"
test "${LIVE_TAG_OBJECT}" = "${TAG_OBJECT_SHA}"
- name: Attest the exact downloaded distributions
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: "dist/*.whl,dist/*.tar.gz"
- name: Publish assets to the GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.verify-build.outputs.release_tag }}
run: |
set -euo pipefail
gh release view "$TAG" >/dev/null 2>&1 \
|| gh release create "$TAG" --title "habitable $TAG" --generate-notes
gh release upload "$TAG" dist/* --clobber
pypi-publish:
name: publish to PyPI (Trusted Publishing)
needs: [verify-build, publish-release]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/habitable
permissions:
contents: read # retrieve workflow artifacts; no source build runs in this job
id-token: write # OIDC token for PyPI Trusted Publishing (no long-lived secret)
steps:
- name: Harden Runner (audit mode)
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
with:
egress-policy: audit
- name: Download the exact distributions built, tested, and attested above
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: pypi-distributions
path: dist
- name: Guard the transferred distribution set
env:
RELEASE_COMMIT: ${{ needs.verify-build.outputs.release_commit }}
RELEASE_TAG: ${{ needs.verify-build.outputs.release_tag }}
run: |
mapfile -t WHEELS < <(find dist -maxdepth 1 -type f -name '*.whl' -print)
mapfile -t SDISTS < <(find dist -maxdepth 1 -type f -name '*.tar.gz' -print)
mapfile -t ALL_FILES < <(find dist -maxdepth 1 -type f -print)
if [ "${#WHEELS[@]}" -ne 1 ] || [ "${#SDISTS[@]}" -ne 1 ] \
|| [ "${#ALL_FILES[@]}" -ne 2 ]; then
echo "::error::Expected exactly one wheel and one sdist from the release job."
find dist -maxdepth 1 -type f -print
exit 1
fi
echo "Publishing exact artifacts for $RELEASE_TAG at $RELEASE_COMMIT."
# Trusted Publishing: requires a one-time PyPI "pending publisher" for project
# "habitable" (repo ChelseaKR/habitable, workflow release.yml, environment pypi).
# See docs/releasing.md. No API token needed once configured.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2