Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,11 @@ jobs:
cache-suffix: lint
# The ruff and ty hooks are `uv run --no-sync`, so the venv must exist
# before prek runs.
- run: uv sync --frozen
# --locked, not --frozen: --frozen installs from the lockfile without
# checking it is current, so drift merges silently. Expect one red run
# on a Release PR -- release-please pushes the version bump, this fails,
# then release.yaml's sync-lockfile commits the lockfile and it passes.
- run: uv sync --locked
# Runs the hooks from .pre-commit-config.yaml rather than repeating them
# here, so local and CI cannot drift. zizmor is skipped: it has its own
# job below, which the ruleset requires by name.
Expand All@@ -48,7 +52,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- run: uv sync --frozen
- run: uv sync --locked
- run: uv run pytest -q

bootstrap:
Expand Down
153 changes: 128 additions & 25 deletions .github/workflows/release.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,12 @@ on:
# first's createCommitOnBranch fails its expectedHeadOid. Queue rather than
# cancel -- cancelling a run part-way through publishing is worse than waiting.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Deliberately not keyed on github.ref. This workflow only ever acts on the
# default branch -- release-please's target-branch defaults to it regardless
# of the ref the run started from -- so a ref-keyed group would put a run
# started from anywhere else into a separate group and let it race the very
# thing this block serialises.
group: ${{ github.workflow }}
cancel-in-progress: false

permissions: {}
Expand All@@ -23,7 +28,6 @@ jobs:
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
tag_name: ${{ steps.rp.outputs.tag_name }}
prs: ${{ steps.rp.outputs.prs }}
steps:
# Mint a short-lived App token so the Release PR is authored by the App
# rather than github-actions[bot]. PRs opened with the default
Expand All@@ -48,10 +52,8 @@ jobs:

sync-lockfile:
needs: release-please
if: needs.release-please.outputs.prs && needs.release-please.outputs.prs != '[]'
runs-on: ubuntu-latest
permissions:
contents: read
permissions: {}
steps:
- id: create_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
Expand All@@ -61,49 +63,150 @@ jobs:
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-pull-requests: read
# Resolve the Release PR from the open PR list, not from release-please's
# `prs` output. That output is set only when release-please actually
# updated a PR, and it declines to update when the regenerated body is
# unchanged (manifest.ts, maybeUpdateExistingPullRequest). A sync missed
# once -- a transient failure, or this job not existing yet -- would then
# never be retried until the next releasable commit arrived. "Is there an
# open Release PR?" is the precondition this job actually cares about.
#
# Three details, each of which was a bug before it was a comment:
#
# 1. `gh pr list --label` is NOT used. It resolves through `query
# PullRequestSearch` -- the search API -- which is index-lagged and
# can miss a PR release-please created seconds earlier in the
# previous job. The unfiltered list hits repository.pullRequests
# and is read-your-writes.
# 2. --limit 201. The default is 30, ordered CREATED_AT DESC. The
# Release PR is long-lived, so it is the OLDEST open PR and sorts
# last -- on a busy repo it drops off page one and the job exits
# green having synced nothing.
# 3. The selector checks author and branch prefix, not just the label.
# A label is mutable by anyone with write access, and the next step
# runs `uv lock`, which executes build backends from the tree it
# checked out.
- id: pr_branch
env:
PRS_JSON: ${{ needs.release-please.outputs.prs }}
GH_TOKEN: ${{ steps.create_token.outputs.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
branch=$(printf '%s' "$PRS_JSON" | jq -r '.[0].headBranchName')
if [[ -z "$branch" || "$branch" == "null" ]]; then
echo "release-please did not report a head branch; skipping" >&2
echo "skip=true" >> "$GITHUB_OUTPUT"
gh pr list --state open --limit 201 \
--json headRefName,labels,author,isCrossRepository > /tmp/prs.json
# Every guard below reads this file, so an empty or truncated fetch
# would make all of them agree there is nothing to do -- the exact
# silent-green outcome this job exists to prevent. Check the file
# itself before trusting anything derived from it.
if [[ ! -s /tmp/prs.json ]]; then
echo "::error::gh pr list produced no output"
exit 1
fi
# 201 requested, so >200 is unambiguously truncation rather than a
# repo that happens to have exactly the limit open. The Release PR is
# the oldest open PR, so it is precisely the one that falls off.
if [[ "$(jq length /tmp/prs.json)" -gt 200 ]]; then
echo "::error::open PR list was truncated; raise the limit -- the Release PR may not be in this page"
exit 1
fi
# Matched on is_bot plus a substring, not an exact login. GitHub
# renders this one identity three ways and it is easy to "fix" this
# comparison into a silent no-match:
# gh pr list --json author -> app/semantic-release-pusher
# REST pulls/{n} user.login -> semantic-release-pusher[bot]
# GraphQL Bot.login -> semantic-release-pusher
# Only the first is what this step reads. The substring match holds
# for all three, so gh changing its normalisation cannot silently
# break the lookup.
filter='map(select(
.author.is_bot == true and
(.author.login | contains("semantic-release-pusher")) and
.isCrossRepository == false and
(.headRefName | startswith("release-please--branches--")) and
((.labels // []) | any(.name == "autorelease: pending"))))'
count=$(jq "$filter | length" /tmp/prs.json)
if [[ "$count" -gt 1 ]]; then
jq -r "$filter | .[].headRefName" /tmp/prs.json
echo "::error::$count Release PRs matched; refusing to guess"
exit 1
fi
branch=$(jq -r "$filter | .[0].headRefName // empty" /tmp/prs.json)
if [[ -z "$branch" ]]; then
# An open PR on a release branch that the selector did not match
# means the selector is wrong, not that there is nothing to do.
# Exiting green there is the failure this whole job exists to
# avoid, so make it loud and print what was actually seen.
# Scoped to same-repo PRs: a fork cannot create a branch here, so
# a drive-by fork PR named release-please--branches--* must not be
# able to fail every release run.
if jq -e 'any(.isCrossRepository == false and (.headRefName |
startswith("release-please--branches--")))' /tmp/prs.json > /dev/null; then
jq -r '.[] | select(.isCrossRepository == false and (.headRefName |
startswith("release-please--branches--"))) |
"\(.headRefName) author=\(.author.login) bot=\(.author.is_bot) labels=\([.labels[]?.name] | join(","))"' /tmp/prs.json
echo "::error::a release-branch PR is open but the selector did not match it; check the autorelease label and release-please's label config, then re-run this workflow"
exit 1
fi
echo "no open Release PR; nothing to sync"
else
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "Release PR branch: $branch"
fi
- if: steps.pr_branch.outputs.skip != 'true'
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- if: steps.pr_branch.outputs.branch != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.pr_branch.outputs.branch }}
token: ${{ steps.create_token.outputs.token }}
persist-credentials: true
- if: steps.pr_branch.outputs.skip != 'true'
# Nothing here pushes over git -- the commit goes through the GraphQL
# API below -- so the App token has no reason to persist in .git/config.
persist-credentials: false
- if: steps.pr_branch.outputs.branch != ''
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.13"
enable-cache: false
- if: steps.pr_branch.outputs.skip != 'true'
name: Sync uv.lock and commit (signed) if changed
# Deliberately holds no token. `uv lock` runs build backends out of the
# checked-out pyproject.toml, on a branch selected partly by a mutable
# label; keeping the App token out of this step means that code never
# sees it. Only the next step, which touches no project code, gets it.
- if: steps.pr_branch.outputs.branch != ''
id: relock
name: Re-lock uv.lock
run: |
set -euo pipefail
# Unset rather than set to "0". UV_FROZEN=1 turns `uv lock` into a
# no-op that exits 0 AND degrades `uv lock --check` to a warning that
# also exits 0 -- so an assertion made while trusting the variable is
# disabled by exactly the condition it exists to detect. Unsetting
# makes both the lock and the assertion independent of the ambient
# environment, which is the only way the guard means anything.
unset UV_FROZEN
uv lock
uv lock --check
if [[ -z "$(git status --porcelain uv.lock)" ]]; then
echo "uv.lock already in sync"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
base64 -w0 < uv.lock > /tmp/uv.lock.b64
{
echo "changed=true"
echo "head_sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"
- if: steps.relock.outputs.changed == 'true'
name: Commit the lockfile, signed, through the API
env:
UV_FROZEN: "0"
GH_TOKEN: ${{ steps.create_token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
BRANCH: ${{ steps.pr_branch.outputs.branch }}
HEAD_SHA: ${{ steps.relock.outputs.head_sha }}
# Uses the GraphQL createCommitOnBranch mutation, not git commit/push.
# Commits made via the API on an App's behalf are signed by GitHub's
# app-flow key, which the required_signatures ruleset on main demands;
# a plain git commit from the runner is unsigned and blocks the PR.
run: |
set -euo pipefail
uv lock
if [[ -z "$(git status --porcelain uv.lock)" ]]; then
echo "uv.lock already in sync"
exit 0
fi
head_sha=$(git rev-parse HEAD)
base64 -w0 < uv.lock > /tmp/uv.lock.b64
# Request built with jq --rawfile and submitted via --input. Two
# failure modes this avoids:
# 1. Inline `-f content=$b64` exceeds MAX_ARG_STRLEN (128KB per
Expand All@@ -126,7 +229,7 @@ jobs:
--arg query "$query" \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "$BRANCH" \
--arg sha "$head_sha" \
--arg sha "$HEAD_SHA" \
--rawfile content /tmp/uv.lock.b64 \
'{
query: $query,
Expand Down
18 changes: 18 additions & 0 deletions Taskfile.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,24 @@ tasks:
- task: dev:typecheck
- task: dev:test

# Two separate traps, both of which produce a silent no-op rather than an
# error, so neither is visible without deliberately testing for it:
#
# 1. A task-level `env:` block does not win. Task will not override a
# variable already present in the OS environment, which is where mise
# puts UV_FROZEN. Hence `env -u`.
# 2. `env -u` alone is not enough either. If `uv` on PATH is a mise *shim*
# rather than the real binary, the shim re-derives [env] from mise.toml
# inside the child process and puts UV_FROZEN back after `env -u` has
# stripped it. `mise which` resolves past the shim to the real binary;
# `command -v` covers the case where uv is not mise-managed at all.
uv:lock:
desc: >-
Re-lock uv.lock. The only command that should change it; UV_FROZEN is "1"
elsewhere so nothing rewrites the lockfile as a side effect.
cmds:
- env -u UV_FROZEN "$(mise which uv 2>/dev/null || command -v uv)" lock

dev:hooks:
desc: Run all prek hooks against every file
cmds:
Expand Down
20 changes: 17 additions & 3 deletions bootstrap
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,6 +67,19 @@ for pair in "PROJECT_NAME:$PROJECT_NAME" "CLI_NAME:$CLI_NAME"; do
}
done

# uv with UV_FROZEN cleared, resolved past any mise shim. Used only where uv
# must write uv.lock. An explicit `UV_FROZEN=0` is not sufficient: if uv on PATH
# is a mise shim it re-derives [env] from mise.toml inside the child process and
# restores the "1", so the write is silently skipped. Same reasoning as
# Taskfile.yml's uv:lock task.
#
# The final `uv sync` does NOT need this: the substitution above rewrites the
# project name inside uv.lock as well, so the lockfile is already consistent
# with the renamed pyproject.toml and --frozen is satisfied.
uv_unfrozen() {
env -u UV_FROZEN "$(mise which uv 2> /dev/null || command -v uv)" "$@"
}

# Everything past this point rewrites the tree in place, so a failure leaves
# a half-converted checkout. Say how to get back rather than dying silently.
recover() {
Expand DownExpand Up@@ -280,9 +293,10 @@ if $WANT_DDD; then
# between the marker and end of file.
sed -i.bak '/^# \[tool.importlinter\]/,$ s/^# \{0,1\}//' pyproject.toml
rm -f pyproject.toml.bak
# UV_FROZEN=1 comes from mise.toml and makes `uv add` refuse to touch
# the lockfile. Override for this one call.
UV_FROZEN=0 uv add --dev --quiet 'import-linter>=2'
# `uv add` does not refuse under UV_FROZEN=1 -- it rewrites pyproject.toml,
# leaves uv.lock stale and still exits 0 -- so clearing the variable is what
# keeps the two in step, not a way past a hard error.
uv_unfrozen add --dev --quiet 'import-linter>=2'
python3 << 'PY'
import pathlib

Expand Down
Loading