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.
Files changed (43) hide show
  1. sillybos-0.1.0/.editorconfig +18 -0
  2. sillybos-0.1.0/.githooks/commit-msg +92 -0
  3. sillybos-0.1.0/.github/dependabot.yml +20 -0
  4. sillybos-0.1.0/.github/workflows/ci.yml +187 -0
  5. sillybos-0.1.0/.github/workflows/pr-title.yml +52 -0
  6. sillybos-0.1.0/.github/workflows/release.yml +247 -0
  7. sillybos-0.1.0/.github/workflows/security.yml +65 -0
  8. sillybos-0.1.0/.github/zizmor.yml +10 -0
  9. sillybos-0.1.0/.gitignore +19 -0
  10. sillybos-0.1.0/.gitleaks.toml +7 -0
  11. sillybos-0.1.0/AGENTS.md +96 -0
  12. sillybos-0.1.0/CHANGELOG.md +13 -0
  13. sillybos-0.1.0/CITATION.cff +18 -0
  14. sillybos-0.1.0/CLAUDE.md +9 -0
  15. sillybos-0.1.0/CODE_OF_CONDUCT.md +13 -0
  16. sillybos-0.1.0/CONTRIBUTING.md +132 -0
  17. sillybos-0.1.0/LICENSE +21 -0
  18. sillybos-0.1.0/PKG-INFO +245 -0
  19. sillybos-0.1.0/README.md +208 -0
  20. sillybos-0.1.0/SECURITY.md +47 -0
  21. sillybos-0.1.0/cliff.toml +84 -0
  22. sillybos-0.1.0/docs/logo.png +0 -0
  23. sillybos-0.1.0/lefthook.yml +47 -0
  24. sillybos-0.1.0/pyproject.toml +127 -0
  25. sillybos-0.1.0/src/sillybos/__init__.py +49 -0
  26. sillybos-0.1.0/src/sillybos/_version.py +24 -0
  27. sillybos-0.1.0/src/sillybos/check.py +206 -0
  28. sillybos-0.1.0/src/sillybos/cli.py +119 -0
  29. sillybos-0.1.0/src/sillybos/headers.py +236 -0
  30. sillybos-0.1.0/src/sillybos/pdf.py +126 -0
  31. sillybos-0.1.0/src/sillybos/pillow.py +49 -0
  32. sillybos-0.1.0/src/sillybos/policy.py +91 -0
  33. sillybos-0.1.0/src/sillybos/py.typed +0 -0
  34. sillybos-0.1.0/src/sillybos/sniff.py +120 -0
  35. sillybos-0.1.0/tests/__init__.py +0 -0
  36. sillybos-0.1.0/tests/corpus.py +146 -0
  37. sillybos-0.1.0/tests/test_check.py +191 -0
  38. sillybos-0.1.0/tests/test_cli.py +68 -0
  39. sillybos-0.1.0/tests/test_headers.py +142 -0
  40. sillybos-0.1.0/tests/test_pdf.py +86 -0
  41. sillybos-0.1.0/tests/test_pillow.py +37 -0
  42. sillybos-0.1.0/tests/test_sniff.py +60 -0
  43. 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 .