Skip to content

Commit ad654ec

Browse files
authored
chore(release): harden supply chain with signed commits, container images, and TestPyPI (#119)
1 parent db3decf commit ad654ec

15 files changed

Lines changed: 993 additions & 41 deletions

File tree

.dockerignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
**/*
2+
3+
!pyproject.toml
4+
!uv.lock
5+
!README.md
6+
!LICENSE
7+
!httptap/
8+
9+
httptap/**/__pycache__
10+
httptap/**/*.pyc
11+
httptap/**/*.pyo

.editorconfig

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# https://editorconfig.org/
2+
3+
root = true
4+
5+
[*]
6+
charset = utf-8
7+
end_of_line = lf
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
10+
indent_style = space
11+
indent_size = 4
12+
13+
[*.{yml,yaml,json,toml,md}]
14+
indent_size = 2
15+
16+
[Makefile]
17+
indent_style = tab
18+
19+
[*.md]
20+
# Trailing whitespace in Markdown forms <br>.
21+
trim_trailing_whitespace = false

.github/workflows/ci.yml

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ jobs:
3737
with:
3838
args: format --check .
3939

40+
- name: Lint Dockerfile with hadolint
41+
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
42+
with:
43+
dockerfile: Dockerfile
44+
failure-threshold: warning
45+
4046
type-check:
4147
name: Type Check
4248
runs-on: ubuntu-latest
@@ -152,6 +158,49 @@ jobs:
152158
compression-level: 6
153159
if-no-files-found: error
154160

161+
container-build:
162+
name: Container Build (${{ matrix.platform }})
163+
runs-on: ubuntu-latest
164+
timeout-minutes: 15
165+
needs:
166+
- lint
167+
strategy:
168+
fail-fast: false
169+
matrix:
170+
platform:
171+
- linux/amd64
172+
- linux/arm64
173+
174+
steps:
175+
- name: Checkout code
176+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
177+
with:
178+
persist-credentials: false
179+
180+
- name: Set up QEMU
181+
if: matrix.platform != 'linux/amd64'
182+
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
183+
184+
- name: Set up Docker Buildx
185+
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
186+
187+
- name: Build and load image
188+
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
189+
with:
190+
context: .
191+
platforms: ${{ matrix.platform }}
192+
push: false
193+
load: true
194+
tags: httptap:ci-smoke
195+
cache-from: type=gha,scope=${{ matrix.platform }}
196+
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
197+
198+
- name: Smoke test (httptap --version)
199+
run: |
200+
OUTPUT=$(docker run --rm --platform=${{ matrix.platform }} httptap:ci-smoke --version)
201+
echo "$OUTPUT"
202+
echo "$OUTPUT" | grep -E '^httptap [0-9]+\.[0-9]+\.[0-9]+'
203+
155204
all-checks-pass:
156205
name: All Checks Passed
157206
if: ${{ !cancelled() }}
@@ -160,6 +209,7 @@ jobs:
160209
- type-check
161210
- test
162211
- build
212+
- container-build
163213
runs-on: ubuntu-latest
164214

165215
steps:
@@ -169,11 +219,13 @@ jobs:
169219
echo "type-check: $TYPE_CHECK_RESULT"
170220
echo "test: $TEST_RESULT"
171221
echo "build: $BUILD_RESULT"
222+
echo "container-build: $CONTAINER_BUILD_RESULT"
172223
173224
if [[ "$LINT_RESULT" != "success" ]] || \
174225
[[ "$TYPE_CHECK_RESULT" != "success" ]] || \
175226
[[ "$TEST_RESULT" != "success" ]] || \
176-
[[ "$BUILD_RESULT" != "success" ]]; then
227+
[[ "$BUILD_RESULT" != "success" ]] || \
228+
[[ "$CONTAINER_BUILD_RESULT" != "success" ]]; then
177229
echo "One or more checks failed"
178230
exit 1
179231
fi
@@ -183,3 +235,4 @@ jobs:
183235
TYPE_CHECK_RESULT: ${{ needs.type-check.result }}
184236
TEST_RESULT: ${{ needs.test.result }}
185237
BUILD_RESULT: ${{ needs.build.result }}
238+
CONTAINER_BUILD_RESULT: ${{ needs.container-build.result }}

.github/workflows/release.yml

Lines changed: 151 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jobs:
3535
environment: release
3636
permissions:
3737
contents: write # Required for git push and tag creation
38+
id-token: write # Required for gitsign keyless Sigstore OIDC signing
3839
outputs:
3940
tag: ${{ steps.version.outputs.tag }}
4041
version: ${{ steps.version.outputs.version }}
@@ -48,10 +49,17 @@ jobs:
4849
ssh-key: ${{ secrets.DEPLOY_KEY }}
4950
persist-credentials: true
5051

52+
- name: Install gitsign (Sigstore keyless git signing)
53+
uses: chainguard-dev/actions/setup-gitsign@de68b87302e6266db5fb5220246f8aa46fe94b67 # v1.6.14
54+
5155
- name: Configure git
5256
run: |
5357
git config user.name "github-actions"
5458
git config user.email "github-actions@github.com"
59+
git config --global gpg.x509.program gitsign
60+
git config --global gpg.format x509
61+
git config --global tag.gpgsign true
62+
git config --global commit.gpgsign true
5563
5664
- name: Set up Python
5765
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -126,13 +134,13 @@ jobs:
126134
VERSION: ${{ steps.version.outputs.version }}
127135
run: |
128136
git add pyproject.toml uv.lock CITATION.cff ${CHANGELOG_FILE}
129-
git commit -m "chore: release v${VERSION}"
137+
git commit -S -m "chore: release v${VERSION}"
130138
131-
- name: Create and push tag
139+
- name: Create and push signed tag
132140
env:
133141
TAG: ${{ steps.version.outputs.tag }}
134142
run: |
135-
git tag -a "${TAG}" -m "Release ${TAG}"
143+
git tag -s "${TAG}" -m "Release ${TAG}"
136144
git push origin HEAD
137145
git push origin "${TAG}"
138146
@@ -212,6 +220,31 @@ jobs:
212220
# is self-identifying.
213221
cp .vex/httptap.openvex.json "sbom/${PACKAGE_NAME}-${VERSION}.openvex.json"
214222
223+
- name: Generate man page
224+
env:
225+
VERSION: ${{ needs.prepare.outputs.version }}
226+
run: |
227+
mkdir -p man
228+
uv tool run --from 'argparse-manpage>=4.7' argparse-manpage \
229+
--pyfile httptap/cli.py \
230+
--function create_parser \
231+
--project-name httptap \
232+
--version "${VERSION}" \
233+
--author "Sergei Ozeranskii" \
234+
--author-email "noreply@httptap.dev" \
235+
--url "https://github.com/ozeranskii/httptap" \
236+
--manual-title "httptap" \
237+
--output "man/${PACKAGE_NAME}.1"
238+
gzip --best "man/${PACKAGE_NAME}.1"
239+
240+
- name: Upload man-page artifact
241+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
242+
with:
243+
name: man
244+
path: man/
245+
retention-days: 7
246+
if-no-files-found: error
247+
215248
- name: Upload SBOM artifacts
216249
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
217250
with:
@@ -220,13 +253,40 @@ jobs:
220253
retention-days: 7
221254
if-no-files-found: error
222255

256+
publish-testpypi:
257+
name: Publish to TestPyPI
258+
runs-on: ubuntu-latest
259+
timeout-minutes: 5
260+
needs:
261+
- prepare
262+
- build
263+
environment:
264+
name: testpypi
265+
url: https://test.pypi.org/project/${{ env.PACKAGE_NAME }}/
266+
permissions:
267+
id-token: write # Required for TestPyPI OIDC trusted publishing
268+
269+
steps:
270+
- name: Download artifacts
271+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
272+
with:
273+
name: dist
274+
path: dist/
275+
276+
- name: Publish to TestPyPI
277+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
278+
with:
279+
repository-url: https://test.pypi.org/legacy/
280+
attestations: true
281+
223282
publish-pypi:
224283
name: Publish to PyPI
225284
runs-on: ubuntu-latest
226285
timeout-minutes: 5
227286
needs:
228287
- prepare
229288
- build
289+
- publish-testpypi
230290
environment:
231291
name: pypi
232292
url: https://pypi.org/project/${{ env.PACKAGE_NAME }}/
@@ -242,6 +302,86 @@ jobs:
242302

243303
- name: Publish to PyPI
244304
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
305+
with:
306+
# PEP 740 attestations are signed via Sigstore OIDC and
307+
# surfaced on the PyPI project page as "Verified publisher".
308+
attestations: true
309+
310+
publish-container:
311+
name: Publish container image to GHCR
312+
runs-on: ubuntu-latest
313+
timeout-minutes: 20
314+
needs:
315+
- prepare
316+
permissions:
317+
contents: read
318+
packages: write # Push to ghcr.io/<owner>/<repo>
319+
id-token: write # Sigstore OIDC for keyless cosign signing
320+
attestations: write # GitHub build provenance attestations
321+
322+
steps:
323+
- name: Checkout tag
324+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
325+
with:
326+
ref: ${{ needs.prepare.outputs.tag }}
327+
persist-credentials: false
328+
329+
- name: Set up QEMU for multi-arch builds
330+
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
331+
332+
- name: Set up Docker Buildx
333+
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
334+
335+
- name: Log in to GitHub Container Registry
336+
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
337+
with:
338+
registry: ghcr.io
339+
username: ${{ github.actor }}
340+
password: ${{ secrets.GITHUB_TOKEN }}
341+
342+
- name: Derive container tags and labels
343+
id: meta
344+
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
345+
with:
346+
images: ghcr.io/${{ github.repository }}
347+
tags: |
348+
type=semver,pattern={{version}},value=${{ needs.prepare.outputs.tag }}
349+
type=semver,pattern={{major}}.{{minor}},value=${{ needs.prepare.outputs.tag }}
350+
type=semver,pattern={{major}},value=${{ needs.prepare.outputs.tag }}
351+
type=raw,value=latest,enable={{is_default_branch}}
352+
labels: |
353+
org.opencontainers.image.revision=${{ github.sha }}
354+
org.opencontainers.image.version=${{ needs.prepare.outputs.version }}
355+
356+
- name: Build and push image
357+
id: build
358+
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
359+
with:
360+
context: .
361+
platforms: linux/amd64,linux/arm64
362+
push: true
363+
tags: ${{ steps.meta.outputs.tags }}
364+
labels: ${{ steps.meta.outputs.labels }}
365+
provenance: mode=max
366+
sbom: true
367+
cache-from: type=gha
368+
cache-to: type=gha,mode=max
369+
370+
- name: Install cosign
371+
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
372+
373+
- name: Sign image with cosign (keyless Sigstore)
374+
env:
375+
IMAGE: ghcr.io/${{ github.repository }}
376+
DIGEST: ${{ steps.build.outputs.digest }}
377+
run: cosign sign --yes "${IMAGE}@${DIGEST}"
378+
379+
- name: Attach SLSA build provenance
380+
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
381+
with:
382+
subject-name: ghcr.io/${{ github.repository }}
383+
subject-digest: ${{ steps.build.outputs.digest }}
384+
push-to-registry: true
245385

246386
create-release:
247387
name: Create GitHub Release
@@ -253,6 +393,7 @@ jobs:
253393
- prepare
254394
- build
255395
- publish-pypi
396+
- publish-container
256397

257398
steps:
258399
- name: Checkout
@@ -272,6 +413,12 @@ jobs:
272413
name: sbom
273414
path: sbom/
274415

416+
- name: Download man-page artifact
417+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
418+
with:
419+
name: man
420+
path: man/
421+
275422
- name: Create GitHub Release
276423
env:
277424
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -280,7 +427,7 @@ jobs:
280427
REPO: ${{ github.repository }}
281428
run: |-
282429
gh release create "$TAG" \
283-
dist/* sbom/* \
430+
dist/* sbom/* man/* \
284431
--repo "$REPO" \
285432
--title "$TAG" \
286433
--notes "$NOTES"

0 commit comments

Comments
 (0)