sillybos 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sillybos-0.1.0/.editorconfig +18 -0
- sillybos-0.1.0/.githooks/commit-msg +92 -0
- sillybos-0.1.0/.github/dependabot.yml +20 -0
- sillybos-0.1.0/.github/workflows/ci.yml +187 -0
- sillybos-0.1.0/.github/workflows/pr-title.yml +52 -0
- sillybos-0.1.0/.github/workflows/release.yml +247 -0
- sillybos-0.1.0/.github/workflows/security.yml +65 -0
- sillybos-0.1.0/.github/zizmor.yml +10 -0
- sillybos-0.1.0/.gitignore +19 -0
- sillybos-0.1.0/.gitleaks.toml +7 -0
- sillybos-0.1.0/AGENTS.md +96 -0
- sillybos-0.1.0/CHANGELOG.md +13 -0
- sillybos-0.1.0/CITATION.cff +18 -0
- sillybos-0.1.0/CLAUDE.md +9 -0
- sillybos-0.1.0/CODE_OF_CONDUCT.md +13 -0
- sillybos-0.1.0/CONTRIBUTING.md +132 -0
- sillybos-0.1.0/LICENSE +21 -0
- sillybos-0.1.0/PKG-INFO +245 -0
- sillybos-0.1.0/README.md +208 -0
- sillybos-0.1.0/SECURITY.md +47 -0
- sillybos-0.1.0/cliff.toml +84 -0
- sillybos-0.1.0/docs/logo.png +0 -0
- sillybos-0.1.0/lefthook.yml +47 -0
- sillybos-0.1.0/pyproject.toml +127 -0
- sillybos-0.1.0/src/sillybos/__init__.py +49 -0
- sillybos-0.1.0/src/sillybos/_version.py +24 -0
- sillybos-0.1.0/src/sillybos/check.py +206 -0
- sillybos-0.1.0/src/sillybos/cli.py +119 -0
- sillybos-0.1.0/src/sillybos/headers.py +236 -0
- sillybos-0.1.0/src/sillybos/pdf.py +126 -0
- sillybos-0.1.0/src/sillybos/pillow.py +49 -0
- sillybos-0.1.0/src/sillybos/policy.py +91 -0
- sillybos-0.1.0/src/sillybos/py.typed +0 -0
- sillybos-0.1.0/src/sillybos/sniff.py +120 -0
- sillybos-0.1.0/tests/__init__.py +0 -0
- sillybos-0.1.0/tests/corpus.py +146 -0
- sillybos-0.1.0/tests/test_check.py +191 -0
- sillybos-0.1.0/tests/test_cli.py +68 -0
- sillybos-0.1.0/tests/test_headers.py +142 -0
- sillybos-0.1.0/tests/test_pdf.py +86 -0
- sillybos-0.1.0/tests/test_pillow.py +37 -0
- sillybos-0.1.0/tests/test_sniff.py +60 -0
- sillybos-0.1.0/uv.lock +617 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
root = true
|
|
2
|
+
|
|
3
|
+
[*]
|
|
4
|
+
charset = utf-8
|
|
5
|
+
end_of_line = lf
|
|
6
|
+
insert_final_newline = true
|
|
7
|
+
trim_trailing_whitespace = true
|
|
8
|
+
indent_style = space
|
|
9
|
+
indent_size = 4
|
|
10
|
+
|
|
11
|
+
[*.{yml,yaml,toml,md,cff}]
|
|
12
|
+
indent_size = 2
|
|
13
|
+
|
|
14
|
+
[*.md]
|
|
15
|
+
trim_trailing_whitespace = false
|
|
16
|
+
|
|
17
|
+
[*.tsv]
|
|
18
|
+
indent_style = tab
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# commit-msg hook — enforce Conventional Commits across all projects.
|
|
3
|
+
#
|
|
4
|
+
# Dependency-free POSIX sh; the identical script lives in every repo under
|
|
5
|
+
# .githooks/ and is shared via `git config core.hooksPath .githooks`.
|
|
6
|
+
#
|
|
7
|
+
# Format: <type>[(scope)][!]: <description>
|
|
8
|
+
# type ....... feat fix docs style refactor perf test build ci chore revert
|
|
9
|
+
# scope ...... optional, e.g. feat(nav): ...
|
|
10
|
+
# ! ......... optional, marks a breaking change
|
|
11
|
+
#
|
|
12
|
+
# Rules (header = first line):
|
|
13
|
+
# - matches the format above with an allowed, lower-case type
|
|
14
|
+
# - description is non-empty and has no trailing full stop
|
|
15
|
+
# - header is 100 characters or fewer (72 or fewer preferred)
|
|
16
|
+
# - a blank line separates the header from any body
|
|
17
|
+
#
|
|
18
|
+
# Skipped for merge, revert, fixup!/squash! and reapply commits. Note the hook
|
|
19
|
+
# runs on local commits only — dependabot and GitHub web-UI commits bypass it.
|
|
20
|
+
# Activate in a fresh clone with: git config core.hooksPath .githooks
|
|
21
|
+
|
|
22
|
+
msg_file="$1"
|
|
23
|
+
commit_source="$2"
|
|
24
|
+
|
|
25
|
+
# Skip auto-generated merge/squash messages.
|
|
26
|
+
case "$commit_source" in
|
|
27
|
+
merge | squash) exit 0 ;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
# Header = first non-comment, non-blank line.
|
|
31
|
+
header=$(grep -v '^#' "$msg_file" | sed '/^[[:space:]]*$/d' | head -n 1)
|
|
32
|
+
|
|
33
|
+
# Skip special commit kinds that have their own conventions.
|
|
34
|
+
case "$header" in
|
|
35
|
+
"Merge "* | "Revert "* | "Reapply "* | "fixup! "* | "squash! "*) exit 0 ;;
|
|
36
|
+
esac
|
|
37
|
+
|
|
38
|
+
if [ -z "$header" ]; then
|
|
39
|
+
echo "✖ commit-msg: empty commit message" >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
types='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
|
|
44
|
+
errors=0
|
|
45
|
+
fail() {
|
|
46
|
+
echo "✖ commit-msg: $1" >&2
|
|
47
|
+
errors=$((errors + 1))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Conventional Commits header: type(scope)!: description
|
|
51
|
+
if printf '%s' "$header" | grep -Eq "^(${types})(\([a-z0-9._/-]+\))?(!)?: .+"; then
|
|
52
|
+
# Description must not end with a full stop.
|
|
53
|
+
desc=$(printf '%s' "$header" | sed -E 's/^[^:]+: //')
|
|
54
|
+
case "$desc" in
|
|
55
|
+
*.) fail "do not end the description with a full stop" ;;
|
|
56
|
+
esac
|
|
57
|
+
else
|
|
58
|
+
fail "header must follow Conventional Commits: <type>[(scope)][!]: <description>"
|
|
59
|
+
echo " allowed types: feat fix docs style refactor perf test build ci chore revert" >&2
|
|
60
|
+
echo " e.g. feat(nav): add mobile menu | fix: correct hero lede fallback" >&2
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# Length: hard limit 100 (commitlint default), recommend 72.
|
|
64
|
+
len=$(printf '%s' "$header" | wc -m | tr -d ' ')
|
|
65
|
+
if [ "$len" -gt 100 ]; then
|
|
66
|
+
fail "header is $len characters; keep it to 100 or fewer"
|
|
67
|
+
elif [ "$len" -gt 72 ]; then
|
|
68
|
+
echo "⚠ commit-msg: header is $len characters; 72 or fewer is preferred" >&2
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# A body, if present, must be separated from the header by a blank line.
|
|
72
|
+
line2=$(sed -n '2p' "$msg_file")
|
|
73
|
+
if [ -n "$line2" ] && [ "${line2#\#}" = "$line2" ]; then
|
|
74
|
+
fail "leave a blank line between the header and the body"
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# Warn (do not block) on over-long lines of prose in the body.
|
|
78
|
+
sed -n '3,$p' "$msg_file" | grep -v '^#' | while IFS= read -r line; do
|
|
79
|
+
case "$line" in
|
|
80
|
+
*" "*)
|
|
81
|
+
bl=$(printf '%s' "$line" | wc -m | tr -d ' ')
|
|
82
|
+
[ "$bl" -gt 72 ] && echo "⚠ commit-msg: body line over 72 characters: $line" >&2
|
|
83
|
+
;;
|
|
84
|
+
esac
|
|
85
|
+
done
|
|
86
|
+
|
|
87
|
+
if [ "$errors" -gt 0 ]; then
|
|
88
|
+
echo "" >&2
|
|
89
|
+
echo "Commit rejected. Fix the message above and try again." >&2
|
|
90
|
+
exit 1
|
|
91
|
+
fi
|
|
92
|
+
exit 0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Dependabot keeps uv.lock and the workflow actions current. Commit-message
|
|
2
|
+
# prefixes are set so its PRs satisfy the Conventional Commits hook that
|
|
3
|
+
# local commits go through (Dependabot commits on GitHub's side bypass it).
|
|
4
|
+
version: 2
|
|
5
|
+
updates:
|
|
6
|
+
- package-ecosystem: "uv"
|
|
7
|
+
directory: "/"
|
|
8
|
+
schedule:
|
|
9
|
+
interval: "weekly"
|
|
10
|
+
commit-message:
|
|
11
|
+
prefix: "build"
|
|
12
|
+
include: "scope"
|
|
13
|
+
|
|
14
|
+
- package-ecosystem: "github-actions"
|
|
15
|
+
directory: "/"
|
|
16
|
+
schedule:
|
|
17
|
+
interval: "weekly"
|
|
18
|
+
commit-message:
|
|
19
|
+
prefix: "ci"
|
|
20
|
+
include: "scope"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Lint, type-check and test on every push and pull request.
|
|
2
|
+
#
|
|
3
|
+
# The package has no runtime dependencies, so the matrix is cheap: every
|
|
4
|
+
# supported Python, the full test suite with the coverage floor from
|
|
5
|
+
# pyproject.toml, and a wheel smoke test on one of them. poppler is
|
|
6
|
+
# installed so the PDF tests run rather than skip.
|
|
7
|
+
#
|
|
8
|
+
# Test (Python X) ruff, mypy, pytest with coverage, the wheel
|
|
9
|
+
# Lockfile is current uv.lock matches pyproject.toml
|
|
10
|
+
# Trivy scan HIGH/CRITICAL fixed vulnerabilities in what
|
|
11
|
+
# uv.lock resolves
|
|
12
|
+
# Security zizmor on the workflows, gitleaks on the
|
|
13
|
+
# whole history
|
|
14
|
+
# Shared templates are current cliff.toml and .githooks/commit-msg match
|
|
15
|
+
# scattercode/release-pipelines
|
|
16
|
+
name: CI
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
branches: [main]
|
|
21
|
+
pull_request:
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
test:
|
|
28
|
+
name: Test (Python ${{ matrix.python }})
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
strategy:
|
|
31
|
+
fail-fast: false
|
|
32
|
+
matrix:
|
|
33
|
+
python: ["3.11", "3.12", "3.13", "3.14"]
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v7
|
|
36
|
+
with:
|
|
37
|
+
# hatch-vcs derives the version from `git describe --tags`; a
|
|
38
|
+
# shallow clone has no tags reachable and the package would
|
|
39
|
+
# report 0.1.dev1+g<sha>.
|
|
40
|
+
fetch-depth: 0
|
|
41
|
+
persist-credentials: false
|
|
42
|
+
|
|
43
|
+
- uses: actions/setup-python@v7
|
|
44
|
+
with:
|
|
45
|
+
python-version: ${{ matrix.python }}
|
|
46
|
+
cache: pip
|
|
47
|
+
|
|
48
|
+
- name: Install poppler, for the PDF tests
|
|
49
|
+
run: sudo apt-get install -y --no-install-recommends poppler-utils
|
|
50
|
+
|
|
51
|
+
- name: Install the package with the development extra
|
|
52
|
+
run: pip install -e '.[dev]'
|
|
53
|
+
|
|
54
|
+
- name: Lint
|
|
55
|
+
run: |
|
|
56
|
+
ruff check .
|
|
57
|
+
ruff format --check .
|
|
58
|
+
|
|
59
|
+
- name: Type-check
|
|
60
|
+
run: mypy
|
|
61
|
+
|
|
62
|
+
- name: Test
|
|
63
|
+
run: pytest --cov --cov-report=term-missing
|
|
64
|
+
|
|
65
|
+
# The wheel, not the editable install: catches files that work from
|
|
66
|
+
# `pip install -e .` but were left out of the wheel.
|
|
67
|
+
- name: Build and smoke-test the wheel
|
|
68
|
+
if: matrix.python == '3.12'
|
|
69
|
+
run: |
|
|
70
|
+
pip install --quiet build twine
|
|
71
|
+
python -m build
|
|
72
|
+
twine check --strict dist/*
|
|
73
|
+
python -m venv /tmp/smoke
|
|
74
|
+
/tmp/smoke/bin/pip install --quiet dist/*.whl
|
|
75
|
+
printf '%%!PS-Adobe-3.0 EPSF-3.0\n' > /tmp/page.png
|
|
76
|
+
if /tmp/smoke/bin/sillybos /tmp/page.png; then echo "::error::a PostScript file passed"; exit 1; fi
|
|
77
|
+
/tmp/smoke/bin/python -c "import sillybos; print(sillybos.__version__)"
|
|
78
|
+
|
|
79
|
+
lockfile:
|
|
80
|
+
name: Lockfile is current
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v7
|
|
84
|
+
with:
|
|
85
|
+
persist-credentials: false
|
|
86
|
+
# uv.lock pins the resolved development tooling so scanners see real
|
|
87
|
+
# versions, not ranges. Regenerate with `uv lock` after any
|
|
88
|
+
# pyproject.toml change.
|
|
89
|
+
- uses: astral-sh/setup-uv@v7
|
|
90
|
+
- run: uv lock --check
|
|
91
|
+
|
|
92
|
+
# Same arrangement as the sibling repositories. The committed uv.lock is
|
|
93
|
+
# what makes the scan worthwhile for Python: pyproject.toml declares
|
|
94
|
+
# ranges, but the lock carries every resolved version.
|
|
95
|
+
trivy:
|
|
96
|
+
name: Trivy scan
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
steps:
|
|
99
|
+
- uses: actions/checkout@v7
|
|
100
|
+
with:
|
|
101
|
+
persist-credentials: false
|
|
102
|
+
|
|
103
|
+
- name: Scan for vulnerabilities
|
|
104
|
+
uses: aquasecurity/trivy-action@v0.36.0
|
|
105
|
+
with:
|
|
106
|
+
scan-type: fs
|
|
107
|
+
scan-ref: .
|
|
108
|
+
format: table
|
|
109
|
+
exit-code: "1"
|
|
110
|
+
ignore-unfixed: true
|
|
111
|
+
vuln-type: library
|
|
112
|
+
severity: HIGH,CRITICAL
|
|
113
|
+
|
|
114
|
+
- name: Generate SBOM
|
|
115
|
+
uses: aquasecurity/trivy-action@v0.36.0
|
|
116
|
+
if: always()
|
|
117
|
+
with:
|
|
118
|
+
scan-type: fs
|
|
119
|
+
scan-ref: .
|
|
120
|
+
format: cyclonedx
|
|
121
|
+
output: sbom.cdx.json
|
|
122
|
+
exit-code: "0"
|
|
123
|
+
|
|
124
|
+
- name: Upload SBOM
|
|
125
|
+
uses: actions/upload-artifact@v7
|
|
126
|
+
if: always()
|
|
127
|
+
with:
|
|
128
|
+
name: sbom-source-tree
|
|
129
|
+
path: sbom.cdx.json
|
|
130
|
+
retention-days: 90
|
|
131
|
+
|
|
132
|
+
# Static analysis of the workflows (zizmor: injection, unpinned actions,
|
|
133
|
+
# over-broad permissions) and a scan of the whole history for secrets
|
|
134
|
+
# (gitleaks). Both run before push and commit through lefthook too; this
|
|
135
|
+
# is the backstop for a laptop without the hooks.
|
|
136
|
+
security:
|
|
137
|
+
name: Security
|
|
138
|
+
runs-on: ubuntu-latest
|
|
139
|
+
steps:
|
|
140
|
+
- uses: actions/checkout@v7
|
|
141
|
+
with:
|
|
142
|
+
fetch-depth: 0
|
|
143
|
+
persist-credentials: false
|
|
144
|
+
|
|
145
|
+
- uses: astral-sh/setup-uv@v7
|
|
146
|
+
|
|
147
|
+
- name: Workflows
|
|
148
|
+
run: uvx zizmor --min-severity medium .github/workflows
|
|
149
|
+
|
|
150
|
+
# The gitleaks binary rather than gitleaks/gitleaks-action, which
|
|
151
|
+
# needs a licence key (GITLEAKS_LICENSE) in a repository owned by an
|
|
152
|
+
# organisation. Pinned to a release and its checksum.
|
|
153
|
+
- name: Secrets in the history
|
|
154
|
+
env:
|
|
155
|
+
GITLEAKS_VERSION: 8.30.1
|
|
156
|
+
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
|
157
|
+
run: |
|
|
158
|
+
set -euo pipefail
|
|
159
|
+
tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
|
160
|
+
curl -fsSLO "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}"
|
|
161
|
+
echo "${GITLEAKS_SHA256} ${tarball}" | sha256sum --check -
|
|
162
|
+
tar -xzf "$tarball" gitleaks
|
|
163
|
+
./gitleaks git --no-banner --redact --verbose .
|
|
164
|
+
|
|
165
|
+
# cliff.toml and .githooks/commit-msg are copies of the shared templates in
|
|
166
|
+
# scattercode/release-pipelines. They have to be real files here -- git-cliff
|
|
167
|
+
# reads one from the repository root and git runs the other from the checkout
|
|
168
|
+
# -- so they cannot be referenced remotely the way the next-version action
|
|
169
|
+
# is. This is what stops a copy drifting unnoticed.
|
|
170
|
+
templates:
|
|
171
|
+
name: Shared templates are current
|
|
172
|
+
runs-on: ubuntu-latest
|
|
173
|
+
steps:
|
|
174
|
+
- uses: actions/checkout@v7
|
|
175
|
+
with:
|
|
176
|
+
persist-credentials: false
|
|
177
|
+
|
|
178
|
+
- name: Check out the shared templates
|
|
179
|
+
uses: actions/checkout@v7
|
|
180
|
+
with:
|
|
181
|
+
repository: scattercode/release-pipelines
|
|
182
|
+
ref: v1
|
|
183
|
+
path: .release-pipelines
|
|
184
|
+
persist-credentials: false
|
|
185
|
+
|
|
186
|
+
- name: Compare
|
|
187
|
+
run: ./.release-pipelines/sync.sh --check .
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# The pull request title must be a Conventional Commit.
|
|
2
|
+
#
|
|
3
|
+
# Why this exists as its own workflow, and not as a job in ci.yml.
|
|
4
|
+
#
|
|
5
|
+
# Pull requests here are squash-merged, so the PR *title* becomes the commit
|
|
6
|
+
# message on main -- and that message is what git-cliff reads to compute the
|
|
7
|
+
# next version. A prose title is not a Conventional Commit, so it parses as
|
|
8
|
+
# nothing releasable: Release runs, finds no feat or fix since the last tag,
|
|
9
|
+
# exits successfully in ten seconds, and cuts nothing. Nothing fails. The
|
|
10
|
+
# first visible symptom is unrelated -- the published Sphinx docs reporting a
|
|
11
|
+
# dev version, because main is sitting untagged.
|
|
12
|
+
#
|
|
13
|
+
# That is exactly how #79 shipped ten conventional commits behind the title
|
|
14
|
+
# "Close brief 012, measure the vision-language backends, and stop
|
|
15
|
+
# transcribing benchmark tables", and released none of them.
|
|
16
|
+
#
|
|
17
|
+
# The check reuses .githooks/commit-msg, the same script the local hook runs,
|
|
18
|
+
# rather than a second definition of "conventional" that would drift from it
|
|
19
|
+
# -- the reason that script is shared from scattercode/release-pipelines in
|
|
20
|
+
# the first place. The hook's own comment names this gap: it "runs on local
|
|
21
|
+
# commits only -- dependabot and GitHub web-UI commits bypass it", and a
|
|
22
|
+
# squash-merge is a web-UI commit.
|
|
23
|
+
#
|
|
24
|
+
# Separate from ci.yml so it reports in seconds, whatever the pull request
|
|
25
|
+
# touches, and fails on its own line rather than inside a test run.
|
|
26
|
+
name: PR title
|
|
27
|
+
|
|
28
|
+
on:
|
|
29
|
+
pull_request:
|
|
30
|
+
types: [opened, edited, reopened, synchronize]
|
|
31
|
+
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
conventional:
|
|
37
|
+
name: PR title is a Conventional Commit
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v7
|
|
41
|
+
with:
|
|
42
|
+
persist-credentials: false
|
|
43
|
+
|
|
44
|
+
# Through the environment, never interpolated into the shell. A pull
|
|
45
|
+
# request title is attacker-controlled text, and `run: ... ${{ ... }}`
|
|
46
|
+
# would substitute it into the script before bash ever sees it.
|
|
47
|
+
- name: Check the title
|
|
48
|
+
env:
|
|
49
|
+
TITLE: ${{ github.event.pull_request.title }}
|
|
50
|
+
run: |
|
|
51
|
+
printf '%s\n' "$TITLE" > "$RUNNER_TEMP/pr-title"
|
|
52
|
+
./.githooks/commit-msg "$RUNNER_TEMP/pr-title"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Automated release: the workspace pattern, shared with the sibling repositories.
|
|
2
|
+
#
|
|
3
|
+
# On every push/merge to main, the shared next-version action computes the next
|
|
4
|
+
# semantic version from the Conventional Commit history since the last tag,
|
|
5
|
+
# git-cliff prepends that version's section to CHANGELOG.md, the commit is
|
|
6
|
+
# tagged, and a GitHub Release is published whose notes are that section.
|
|
7
|
+
# The wheel is then built from the tag and published to PyPI with a Trusted
|
|
8
|
+
# Publisher.
|
|
9
|
+
#
|
|
10
|
+
# The release commit is pushed by the `scattercode-release` App, the only
|
|
11
|
+
# actor the main ruleset lets bypass it. That token is a real identity, so
|
|
12
|
+
# unlike GITHUB_TOKEN its pushes *do* trigger workflows -- the `chore(release):`
|
|
13
|
+
# guard on the job below is what stops this looping, not the token. If there
|
|
14
|
+
# are no releasable commits since the last tag, the job is a no-op.
|
|
15
|
+
#
|
|
16
|
+
# A re-run by hand, for a tag whose build or publish failed after the tag
|
|
17
|
+
# and GitHub Release already existed:
|
|
18
|
+
#
|
|
19
|
+
# gh workflow run release.yml --ref main -f tag=0.2.0
|
|
20
|
+
#
|
|
21
|
+
# The release job is skipped and the rest runs for that tag.
|
|
22
|
+
name: Release
|
|
23
|
+
|
|
24
|
+
on:
|
|
25
|
+
push:
|
|
26
|
+
branches: [main]
|
|
27
|
+
workflow_dispatch:
|
|
28
|
+
inputs:
|
|
29
|
+
tag:
|
|
30
|
+
description: An existing release tag to build and publish (for example 0.2.0)
|
|
31
|
+
required: true
|
|
32
|
+
type: string
|
|
33
|
+
|
|
34
|
+
# Granted per job below, not here: a workflow-level grant reaches every job,
|
|
35
|
+
# and only the release job pushes.
|
|
36
|
+
permissions: {}
|
|
37
|
+
|
|
38
|
+
concurrency:
|
|
39
|
+
group: release
|
|
40
|
+
cancel-in-progress: false
|
|
41
|
+
|
|
42
|
+
jobs:
|
|
43
|
+
release:
|
|
44
|
+
# Belt and braces: never react to our own release commit. A dispatch
|
|
45
|
+
# names a tag that already exists, so there is nothing to compute.
|
|
46
|
+
if: ${{ github.event_name == 'push' && !startsWith(github.event.head_commit.message, 'chore(release):') }}
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
permissions:
|
|
49
|
+
contents: write
|
|
50
|
+
outputs:
|
|
51
|
+
released: ${{ steps.version.outputs.release }}
|
|
52
|
+
version: ${{ steps.version.outputs.next }}
|
|
53
|
+
steps:
|
|
54
|
+
# A token for the `scattercode-release` GitHub App, which is the only
|
|
55
|
+
# actor allowed to bypass main's ruleset. GITHUB_TOKEN cannot be given
|
|
56
|
+
# that permission -- GitHub Actions is not an installable app, so it has
|
|
57
|
+
# no identity a ruleset can name. The App holds Contents: write and
|
|
58
|
+
# nothing else, so the worst it can do is commit and tag.
|
|
59
|
+
- name: Mint a release token
|
|
60
|
+
id: app_token
|
|
61
|
+
uses: actions/create-github-app-token@v3
|
|
62
|
+
with:
|
|
63
|
+
app-id: ${{ secrets.RELEASE_CLIENT_ID }}
|
|
64
|
+
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
|
65
|
+
# Scoped to what the job uses, even though the installation grants
|
|
66
|
+
# no more: a token minted without this inherits whatever the App
|
|
67
|
+
# is later given.
|
|
68
|
+
permission-contents: write
|
|
69
|
+
|
|
70
|
+
# The committer line needs the App's bot *user* id, which is not any of
|
|
71
|
+
# the ids the token action returns -- `installation-id` is a different
|
|
72
|
+
# number, and a commit carrying it is attributed to nobody.
|
|
73
|
+
- name: Identify the App's bot user
|
|
74
|
+
id: bot
|
|
75
|
+
env:
|
|
76
|
+
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
|
77
|
+
SLUG: ${{ steps.app_token.outputs.app-slug }}
|
|
78
|
+
run: |
|
|
79
|
+
set -euo pipefail
|
|
80
|
+
echo "id=$(gh api "/users/${SLUG}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
|
81
|
+
|
|
82
|
+
- name: Check out full history
|
|
83
|
+
uses: actions/checkout@v7
|
|
84
|
+
with:
|
|
85
|
+
fetch-depth: 0
|
|
86
|
+
# Persisted so the push below goes out as the App rather than as
|
|
87
|
+
# GITHUB_TOKEN, which the ruleset would refuse.
|
|
88
|
+
token: ${{ steps.app_token.outputs.token }}
|
|
89
|
+
|
|
90
|
+
- name: Install git-cliff
|
|
91
|
+
uses: taiki-e/install-action@v2
|
|
92
|
+
with:
|
|
93
|
+
tool: git-cliff
|
|
94
|
+
|
|
95
|
+
# Shared with every other repository whose releases are automated.
|
|
96
|
+
- name: Compute next version
|
|
97
|
+
id: version
|
|
98
|
+
uses: scattercode/release-pipelines/actions/next-version@v1
|
|
99
|
+
|
|
100
|
+
- name: Guard against a silently skipped release
|
|
101
|
+
if: steps.version.outputs.release != 'true'
|
|
102
|
+
run: |
|
|
103
|
+
latest="$(git describe --tags --abbrev=0 2>/dev/null || echo '')"
|
|
104
|
+
echo "No releasable change since ${latest:-repo start}."
|
|
105
|
+
# A merge to main carrying a feat or fix must produce a release. If
|
|
106
|
+
# one does not, that is the failure this job exists to catch.
|
|
107
|
+
if git log --no-merges --format='%s' "${latest:+$latest..}HEAD" \
|
|
108
|
+
| grep -qE '^(feat|fix|perf|revert)(\([^)]*\))?!?:'; then
|
|
109
|
+
echo "::error::Releasable commits are present but no version was computed."
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
- name: Update changelog, tag and release
|
|
114
|
+
if: steps.version.outputs.release == 'true'
|
|
115
|
+
env:
|
|
116
|
+
VERSION: ${{ steps.version.outputs.next }}
|
|
117
|
+
GH_TOKEN: ${{ github.token }}
|
|
118
|
+
# Passed through the environment rather than expanded into the
|
|
119
|
+
# script: an expression inside `run:` is pasted into the shell
|
|
120
|
+
# before it runs, which is how workflow injection happens.
|
|
121
|
+
BRANCH: ${{ github.ref_name }}
|
|
122
|
+
APP_SLUG: ${{ steps.app_token.outputs.app-slug }}
|
|
123
|
+
BOT_ID: ${{ steps.bot.outputs.id }}
|
|
124
|
+
run: |
|
|
125
|
+
set -euo pipefail
|
|
126
|
+
|
|
127
|
+
# The very first release predates the changelog: git-cliff's
|
|
128
|
+
# --prepend errors on a missing file but handles an empty one
|
|
129
|
+
# correctly (header, then the new section).
|
|
130
|
+
touch CHANGELOG.md
|
|
131
|
+
|
|
132
|
+
# --prepend, not --output: regenerating the whole changelog re-runs
|
|
133
|
+
# git-cliff's release bucketing over the entire history, which is
|
|
134
|
+
# exactly what is unreliable on merge topology.
|
|
135
|
+
git cliff --tag "$VERSION" --unreleased --prepend CHANGELOG.md
|
|
136
|
+
git cliff --tag "$VERSION" --unreleased --strip all --output RELEASE_NOTES.md
|
|
137
|
+
|
|
138
|
+
git config user.name "${APP_SLUG}[bot]"
|
|
139
|
+
git config user.email "${BOT_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
|
|
140
|
+
|
|
141
|
+
git add CHANGELOG.md
|
|
142
|
+
git commit -m "chore(release): ${VERSION#v}"
|
|
143
|
+
git tag -a "$VERSION" -m "$VERSION"
|
|
144
|
+
|
|
145
|
+
# One atomic push, so the branch and the tag land together. A pull
|
|
146
|
+
# request merged into main while this job is running makes this
|
|
147
|
+
# push non-fast-forward and the job fails here; because nothing
|
|
148
|
+
# after it has run, re-running the job is a clean redo.
|
|
149
|
+
git push --atomic origin "HEAD:${BRANCH}" "$VERSION"
|
|
150
|
+
|
|
151
|
+
gh release create "$VERSION" --title "$VERSION" --notes-file RELEASE_NOTES.md
|
|
152
|
+
|
|
153
|
+
# One place that says which tag, if any, the rest of the workflow ships:
|
|
154
|
+
# the tag the release job just pushed, or the one a dispatch named.
|
|
155
|
+
tag:
|
|
156
|
+
needs: release
|
|
157
|
+
if: ${{ always() && !cancelled() }}
|
|
158
|
+
runs-on: ubuntu-latest
|
|
159
|
+
outputs:
|
|
160
|
+
released: ${{ steps.pick.outputs.released }}
|
|
161
|
+
version: ${{ steps.pick.outputs.version }}
|
|
162
|
+
steps:
|
|
163
|
+
- id: pick
|
|
164
|
+
env:
|
|
165
|
+
DISPATCHED_TAG: ${{ inputs.tag }}
|
|
166
|
+
RELEASED: ${{ needs.release.outputs.released }}
|
|
167
|
+
VERSION: ${{ needs.release.outputs.version }}
|
|
168
|
+
run: |
|
|
169
|
+
if [ -n "$DISPATCHED_TAG" ]; then
|
|
170
|
+
echo "released=true" >> "$GITHUB_OUTPUT"
|
|
171
|
+
echo "version=$DISPATCHED_TAG" >> "$GITHUB_OUTPUT"
|
|
172
|
+
else
|
|
173
|
+
echo "released=${RELEASED:-false}" >> "$GITHUB_OUTPUT"
|
|
174
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
# Build the sdist and wheel from the exact tag. A fresh checkout at the
|
|
178
|
+
# tag is what makes hatch-vcs produce a clean X.Y.Z. `!cancelled()` on
|
|
179
|
+
# this and the next gate, not a bare output check: a job whose `if` names
|
|
180
|
+
# no status function gets an implicit success(), which is false when the
|
|
181
|
+
# release job was skipped, as it is on every dispatch.
|
|
182
|
+
build:
|
|
183
|
+
needs: tag
|
|
184
|
+
if: ${{ !cancelled() && needs.tag.outputs.released == 'true' }}
|
|
185
|
+
runs-on: ubuntu-latest
|
|
186
|
+
permissions:
|
|
187
|
+
contents: read
|
|
188
|
+
steps:
|
|
189
|
+
- uses: actions/checkout@v7
|
|
190
|
+
with:
|
|
191
|
+
ref: ${{ needs.tag.outputs.version }}
|
|
192
|
+
fetch-depth: 0
|
|
193
|
+
persist-credentials: false
|
|
194
|
+
|
|
195
|
+
# A dispatched tag could be a branch name or a typo, and
|
|
196
|
+
# actions/checkout would check either out without complaint.
|
|
197
|
+
- name: Check the ref is a tag
|
|
198
|
+
run: git describe --exact-match --tags HEAD
|
|
199
|
+
|
|
200
|
+
- uses: actions/setup-python@v7
|
|
201
|
+
with:
|
|
202
|
+
python-version: "3.12"
|
|
203
|
+
|
|
204
|
+
- name: Build sdist and wheel
|
|
205
|
+
run: |
|
|
206
|
+
python -m pip install --upgrade build twine
|
|
207
|
+
python -m build
|
|
208
|
+
|
|
209
|
+
# Fails on a README that will not render on PyPI, or missing metadata.
|
|
210
|
+
- name: Check distribution metadata
|
|
211
|
+
run: twine check --strict dist/*
|
|
212
|
+
|
|
213
|
+
# Install the *wheel* into a clean interpreter and run it. This catches
|
|
214
|
+
# files that work from `pip install -e .` but were left out of the wheel.
|
|
215
|
+
- name: Smoke-test the wheel
|
|
216
|
+
env:
|
|
217
|
+
VERSION: ${{ needs.tag.outputs.version }}
|
|
218
|
+
run: |
|
|
219
|
+
python -m venv /tmp/smoke
|
|
220
|
+
/tmp/smoke/bin/pip install --quiet dist/*.whl
|
|
221
|
+
/tmp/smoke/bin/python -c "import os, sillybos; v = os.environ['VERSION'].removeprefix('v'); assert sillybos.__version__ == v, (sillybos.__version__, v); print(sillybos.__version__)"
|
|
222
|
+
printf '%%!PS-Adobe-3.0 EPSF-3.0\n' > /tmp/page.png
|
|
223
|
+
if /tmp/smoke/bin/sillybos /tmp/page.png; then echo "::error::a PostScript file passed"; exit 1; fi
|
|
224
|
+
|
|
225
|
+
- uses: actions/upload-artifact@v7
|
|
226
|
+
with:
|
|
227
|
+
name: dist
|
|
228
|
+
path: dist/
|
|
229
|
+
|
|
230
|
+
# Upload with a Trusted Publisher. No token is stored anywhere: the job
|
|
231
|
+
# proves its identity to PyPI with a short-lived OIDC token, which is why
|
|
232
|
+
# id-token: write is the only permission it needs. Attestations (PEP 740)
|
|
233
|
+
# are generated and uploaded by the action automatically.
|
|
234
|
+
publish:
|
|
235
|
+
needs: [tag, build]
|
|
236
|
+
if: ${{ !cancelled() && needs.tag.outputs.released == 'true' && needs.build.result == 'success' }}
|
|
237
|
+
runs-on: ubuntu-latest
|
|
238
|
+
environment: pypi # must match the name registered on PyPI
|
|
239
|
+
permissions:
|
|
240
|
+
id-token: write
|
|
241
|
+
steps:
|
|
242
|
+
- uses: actions/download-artifact@v8
|
|
243
|
+
with:
|
|
244
|
+
name: dist
|
|
245
|
+
path: dist/
|
|
246
|
+
|
|
247
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Weekly security scans -- the same checks CI runs on every push, repeated
|
|
2
|
+
# on a schedule so that CVEs disclosed *between* pushes are caught while
|
|
3
|
+
# the repository is quiet:
|
|
4
|
+
#
|
|
5
|
+
# Trivy fixed HIGH/CRITICAL vulnerabilities in what uv.lock resolves
|
|
6
|
+
# zizmor the workflows themselves
|
|
7
|
+
# gitleaks secrets anywhere in the history
|
|
8
|
+
name: Security
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
schedule:
|
|
12
|
+
- cron: "0 6 * * 1" # Mondays 06:00 UTC
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
trivy:
|
|
20
|
+
name: Trivy scan
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v7
|
|
24
|
+
with:
|
|
25
|
+
persist-credentials: false
|
|
26
|
+
|
|
27
|
+
- name: Scan for vulnerabilities
|
|
28
|
+
uses: aquasecurity/trivy-action@v0.36.0
|
|
29
|
+
with:
|
|
30
|
+
scan-type: fs
|
|
31
|
+
scan-ref: .
|
|
32
|
+
format: table
|
|
33
|
+
exit-code: "1"
|
|
34
|
+
ignore-unfixed: true
|
|
35
|
+
vuln-type: library
|
|
36
|
+
severity: HIGH,CRITICAL
|
|
37
|
+
|
|
38
|
+
security:
|
|
39
|
+
name: Workflows and secrets
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v7
|
|
43
|
+
with:
|
|
44
|
+
fetch-depth: 0
|
|
45
|
+
persist-credentials: false
|
|
46
|
+
|
|
47
|
+
- uses: astral-sh/setup-uv@v7
|
|
48
|
+
|
|
49
|
+
- name: Workflows
|
|
50
|
+
run: uvx zizmor --min-severity medium .github/workflows
|
|
51
|
+
|
|
52
|
+
# The gitleaks binary rather than gitleaks/gitleaks-action, which
|
|
53
|
+
# needs a licence key (GITLEAKS_LICENSE) in a repository owned by an
|
|
54
|
+
# organisation. Pinned to a release and its checksum.
|
|
55
|
+
- name: Secrets in the history
|
|
56
|
+
env:
|
|
57
|
+
GITLEAKS_VERSION: 8.30.1
|
|
58
|
+
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
|
59
|
+
run: |
|
|
60
|
+
set -euo pipefail
|
|
61
|
+
tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
|
62
|
+
curl -fsSLO "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}"
|
|
63
|
+
echo "${GITLEAKS_SHA256} ${tarball}" | sha256sum --check -
|
|
64
|
+
tar -xzf "$tarball" gitleaks
|
|
65
|
+
./gitleaks git --no-banner --redact --verbose .
|