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
18 changes: 9 additions & 9 deletions .envrc
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ fi
# pytest, hypothesis, moto, ...). Works regardless of whether the Nix
# branch above ran.
#
# Use `--frozen` to mirror what CI does: install exactly what's locked,
# without auto-resolving against pyproject.toml. This prevents the
# uv.lock drift footgun where every pull after a release-please bump
# (which updates pyproject.toml's `version` but cannot run `uv lock`)
# would silently rewrite uv.lock and leave your working tree dirty.
# Trade-off: if you add a dep to pyproject.toml without running
# `uv lock`, it won't be installed until you re-lock manually. Same
# trade-off CI accepts; documented in CONTRIBUTING.md.
# Use `--locked` to mirror what CI does. Release-please syncs uv.lock's
# self-version entry alongside pyproject.toml on every release (see
# release-please-config.json's extra-files block), so the historical
# drift footgun (a `git pull` after a release-please bump silently
# rewriting uv.lock) is fixed at the source. With `--locked` here, any
# divergence between pyproject.toml and uv.lock — e.g. a forgotten
# `uv lock` after editing deps — fails fast in your shell, matching the
# CI diagnostic.
if has uv; then
uv sync --all-groups --frozen --quiet
uv sync --all-groups --locked --quiet
fi

if [[ -d .venv ]]; then
Expand Down
15 changes: 8 additions & 7 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,14 @@ public API or the spec'd capabilities, use OpenSpec.
versions. After **any** change to `pyproject.toml` dependencies, run
`uv lock` and commit the resulting `uv.lock` in the same PR.

CI installs with `uv sync --frozen` (not `--locked`). This is intentional:
release-please bumps `version` in `pyproject.toml` for releases but cannot
also run `uv lock`, so the local project's version drifts in `uv.lock`
between releases. `--frozen` tolerates that single drift while still
pinning every dependency version to the lockfile. **It does not catch a
contributor forgetting to run `uv lock`** after adding a dep — please do
so manually.
CI installs with `uv sync --locked` (not `--frozen`). Release-please is
configured to update `uv.lock`'s `cfn-handler` self-version entry
alongside `pyproject.toml` on every release (see the `extra-files`
block in `release-please-config.json`), so the historical drift between
the two files is fixed at the source. **`--locked` therefore catches a
contributor forgetting to run `uv lock` after editing `pyproject.toml`
deps**, surfacing the diagnostic in CI immediately rather than at a
later maintenance step.

## Reporting security issues

Expand Down
16 changes: 9 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ jobs:
cache-suffix: test

- name: Install dependencies
# Use `--frozen` rather than `--locked` so release-please's bump of
# pyproject.toml's `version` (which release-please can't pair with a
# `uv lock`) does not break post-merge CI. `--frozen` still installs
# exactly the dep versions in uv.lock; only the local project's own
# version is read from the (current) pyproject.toml.
run: uv sync --frozen --only-group test
# `--locked` validates that pyproject.toml and uv.lock agree before
# installing. Release-please now keeps the cfn-handler self-version
# entry in uv.lock in lockstep with pyproject.toml (see
# release-please-config.json's extra-files block), so post-merge
# CI on main no longer breaks under --locked. Bonus: contributors
# who edit pyproject.toml deps without running `uv lock` are
# caught by CI immediately.
run: uv sync --locked --only-group test

- name: Run tests with coverage
run: uv run pytest --cov --cov-report=xml --cov-report=term
Expand Down Expand Up @@ -105,7 +107,7 @@ jobs:
cache-suffix: lint

- name: Install lint dependencies
run: uv sync --frozen --only-group lint
run: uv sync --locked --only-group lint

- name: Ruff check
run: uv run ruff check src tests examples
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/examples-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
cache-suffix: examples-lint

- name: Install lint dependencies
run: uv sync --frozen --only-group lint
run: uv sync --locked --only-group lint

- name: cfn-lint over examples
run: uv run cfn-lint examples/**/template.yaml
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,7 @@ scratch/
# act (local GH Actions runner)
.actrc.local
.act-cache/

# Node (only used by tests/release-please/ for local validation; the lockfile
# is committed for reproducibility, the modules are not).
node_modules/
63 changes: 48 additions & 15 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,25 +276,49 @@ SHAs** — bumping `pypa/gh-action-pypi-publish` is fine. It depends on the
workflow filename, which is why renaming `release.yml` requires
re-creating the binding on PyPI first.

### Lockfile drift and `--frozen`
### Lockfile sync via release-please

`release-please-action` bumps `version` in `pyproject.toml` (and
`.release-please-manifest.json`) but **cannot** also run `uv lock` to
update `uv.lock`. Under `uv sync --locked`, that drift would break every
post-merge CI run on `main` immediately after a release-please merge.
`.release-please-manifest.json`) on every release PR. It is also
configured — via the `extra-files` block in
`release-please-config.json` — to update the `cfn-handler` self-version
entry in `uv.lock` in lockstep:

```json
"extra-files": [
{
"type": "toml",
"path": "uv.lock",
"jsonpath": "$.package[?(@.name.value=='cfn-handler')].version"
}
]
```

The jsonpath uses `@.name.value` rather than `@.name`. Release-please's
TOML parser exposes string nodes as `{value, kind}` objects rather
than bare strings, so the bare `@.name=='cfn-handler'` form does not
match. Tracked upstream as
[googleapis/release-please#2455][issue-2455] (the bug);
[#2561][issue-2561] (feature request to make this native);
[#2693][pr-2693] (proposed fix that would let us drop `.value`).

CI uses `uv sync --frozen` instead:
[issue-2455]: https://github.com/googleapis/release-please/issues/2455
[issue-2561]: https://github.com/googleapis/release-please/issues/2561
[pr-2693]: https://github.com/googleapis/release-please/pull/2693

CI uses `uv sync --locked`:

```yaml
- name: Install dependencies
run: uv sync --frozen --only-group test
run: uv sync --locked --only-group test
```

`--frozen` installs exactly the dependency versions recorded in
`uv.lock`; only the local project's own version is read from the current
`pyproject.toml`. The trade-off: a contributor adding a runtime
dependency to `pyproject.toml` without running `uv lock` will not be
caught by CI — see [Lockfile policy in CONTRIBUTING.md](../.github/CONTRIBUTING.md#lockfile-uvlock).
`--locked` validates that `pyproject.toml` and `uv.lock` agree before
installing. Because release-please now keeps both files in sync, post-
merge CI on `main` is no longer broken by release commits. The
secondary benefit: a contributor who edits `pyproject.toml`
dependencies without running `uv lock` is caught by CI immediately —
see [Lockfile policy in CONTRIBUTING.md](../.github/CONTRIBUTING.md#lockfile-uvlock).

### Recovery from a failed publish

Expand Down Expand Up @@ -670,6 +694,13 @@ on bot-authored branches — so the failure didn't surface pre-merge.
its release-please token via a dedicated GitHub App; see
[How release-please PRs trigger required checks](#how-release-please-prs-trigger-required-checks).)

**Status: resolved.** Release-please is now configured to update
`uv.lock`'s self-version entry alongside `pyproject.toml` via
`extra-files` in `release-please-config.json`; CI has been moved
back from `uv sync --frozen` to `uv sync --locked`. Both files
move together on every release; contributor relock omissions are
now caught by CI. See [Lockfile sync via release-please](#lockfile-sync-via-release-please).

### What we changed

1. Repinned `pypa/gh-action-pypi-publish` to its commit SHA.
Expand All @@ -692,10 +723,12 @@ direct, side-effect-free check of exactly the bug class that broke us
effects on the repo).

The `--locked` failure would have surfaced post-merge CI on `main`
either way, but CONTRIBUTING.md's lockfile-policy section now warns
contributors to manually `uv lock` after dependency edits. The release-
please case is unrecoverable without manual reset, which is the failure
mode this postmortem describes.
either way. CONTRIBUTING.md's lockfile-policy section warns
contributors to manually `uv lock` after dependency edits, and CI under
`--locked` now catches the omission automatically (see
[Lockfile sync via release-please](#lockfile-sync-via-release-please)).
The release-please case is unrecoverable without manual reset, which is
the failure mode this postmortem describes.

---

Expand Down
49 changes: 36 additions & 13 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ test-matrix-arm64: _check-act
#
# Sequential, fail-fast, no side effects. Skipped: dependency-review.yml
# (needs PR context act can't synthesize). Requires `act`, `gh` (authenticated),
# and `docker`.
# `docker`, and `npm` (for the release-please validator; ships in the Nix flake).
#
# Steps (each on a fresh container):
# Steps (each on a fresh container unless noted):
# 1. secure-workflows.yml — re-validate SHA pinning of every action
# (~5s). Catches tag-pinned bumps.
# 2. Docker action manifest probe — for every Docker-based action used
Expand All @@ -126,13 +126,21 @@ test-matrix-arm64: _check-act
# release failure) without invoking release.yml — which would
# have real side effects on the repo (release-please-action
# authenticated as the user could open or update real release PRs).
# 3a. ci.yml `test` matrix — amd64 + arm64 × 5 Python versions (~3-5 min).
# 3b. ci.yml `lint` job — ruff, ruff-format, mypy strict, pyright strict
# 3. release-please uv.lock validator — runs the local Node validator
# that loads release-please's GenericToml updater and exercises it
# against our uv.lock + the jsonpath in release-please-config.json
# (~5s). Catches: configured jsonpath stops matching the cfn-handler
# entry; release-please starts re-serialising uv.lock (whole-file
# rewrites instead of surgical edits); upstream PR #2693 lands and
# the .value workaround can be dropped. Runs locally, no network
# after the one-time `npm ci`.
# 4a. ci.yml `test` matrix — amd64 + arm64 × 5 Python versions (~3-5 min).
# 4b. ci.yml `lint` job — ruff, ruff-format, mypy strict, pyright strict
# (~30s).
# 3c. examples-lint.yml — cfn-lint over examples/**/template.yaml (~30s).
# 4. codeql.yml — Python security-and-quality scan (~1-8 min, slower
# 4c. examples-lint.yml — cfn-lint over examples/**/template.yaml (~30s).
# 5. codeql.yml — Python security-and-quality scan (~1-8 min, slower
# on first run while CodeQL bundle downloads).
gha-pre-release: _check-act _check-gh-token _check-docker
gha-pre-release: _check-act _check-gh-token _check-docker _check-npm
#!/usr/bin/env bash
set -uo pipefail

Expand All @@ -141,13 +149,13 @@ gha-pre-release: _check-act _check-gh-token _check-docker
--secret GITHUB_TOKEN="$(gh auth token)"
)

echo "==> [1/6] secure-workflows.yml — SHA-pin enforcement"
echo "==> [1/7] secure-workflows.yml — SHA-pin enforcement"
act pull_request -W .github/workflows/secure-workflows.yml "${common_flags[@]}" \
--action-cache-path /tmp/act-cache-secure-workflows \
|| { echo; echo "FAIL: secure-workflows.yml"; exit 1; }

echo
echo "==> [2/6] Docker action manifest probe"
echo "==> [2/7] Docker action manifest probe"
# Match `uses: <owner>/<repo>@<sha>` in every workflow file, then for any
# action that publishes a Docker image at ghcr.io/<owner>/<repo>, verify
# the SHA resolves to a real image. Currently this is just
Expand Down Expand Up @@ -189,24 +197,36 @@ gha-pre-release: _check-act _check-gh-token _check-docker
echo " (all Docker action images resolve)"

echo
echo "==> [3a/6] ci.yml — test matrix (amd64 + arm64 in parallel)"
echo "==> [3/7] release-please uv.lock validator"
# Loads release-please's GenericToml updater locally and exercises it
# against the real uv.lock + the jsonpath in release-please-config.json.
# `npm ci` is strict-lockfile (matches our uv --locked posture); install
# is ~3s on a small dep tree. See tests/release-please/README.md.
(
cd tests/release-please \
&& npm ci --silent --no-audit --no-fund \
&& node validate-uv-lock-updater.js
) || { echo; echo "FAIL: release-please uv.lock validator"; exit 1; }

echo
echo "==> [4a/7] ci.yml — test matrix (amd64 + arm64 in parallel)"
just test-matrix \
|| { echo; echo "FAIL: ci.yml test matrix"; exit 1; }

echo
echo "==> [3b/6] ci.yml — lint+typecheck job"
echo "==> [4b/7] ci.yml — lint+typecheck job"
act pull_request -W .github/workflows/ci.yml "${common_flags[@]}" --job lint \
--action-cache-path /tmp/act-cache-lint \
|| { echo; echo "FAIL: ci.yml lint job"; exit 1; }

echo
echo "==> [3c/6] examples-lint.yml — cfn-lint over examples"
echo "==> [4c/7] examples-lint.yml — cfn-lint over examples"
act pull_request -W .github/workflows/examples-lint.yml "${common_flags[@]}" \
--action-cache-path /tmp/act-cache-examples-lint \
|| { echo; echo "FAIL: examples-lint.yml"; exit 1; }

echo
echo "==> [4/6] codeql.yml — Python security analysis"
echo "==> [5/7] codeql.yml — Python security analysis"
act push -W .github/workflows/codeql.yml "${common_flags[@]}" \
--action-cache-path /tmp/act-cache-codeql \
|| { echo; echo "FAIL: codeql.yml"; exit 1; }
Expand Down Expand Up @@ -250,3 +270,6 @@ _check-gh-token:
_check-docker:
@command -v docker >/dev/null || { echo 'error: docker not installed. Install Docker Desktop, OrbStack, or Colima.'; exit 1; }
@docker info >/dev/null 2>&1 || { echo 'error: docker daemon not running.'; exit 1; }

_check-npm:
@command -v npm >/dev/null || { echo 'error: npm not installed. Use the Nix dev shell (npm ships via nodejs_20) or install Node 20+.'; exit 1; }
2 changes: 2 additions & 0 deletions openspec/changes/release-please-sync-uv-lock/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-22
Loading
Loading