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: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ updates:
patterns: ["*"]
commit-message:
prefix: "ci"
# Keeps the digest-pinned `FROM ubuntu:noble@sha256:…` in the dev
# container Dockerfile current (Scorecard Pinned-Dependencies, #778).
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
commit-message:
prefix: "chore"
- package-ecosystem: cargo
directory: "/"
schedule:
Expand Down
38 changes: 13 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
name: Release

# Triggered by `v*` tags. `workflow_dispatch` is available for rehearsal
# against an existing tag; the pre-release gate keeps tap/bucket
# against an existing tag — run it *from* the tag
# (`gh workflow run release.yml --ref v0.0.0-test1`, or pick the tag in
# the "Use workflow from" dropdown) rather than naming the tag in an
# input. That way `github.ref` is the tag for both events and nothing
# user-typed ever reaches a checkout (CodeQL cache-poisoning finding
# on the old `inputs.tag`). The pre-release gate keeps tap/bucket
# updates from leaking during rehearsal.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to release (e.g. v0.1.0 or v0.0.0-test1)"
required: true
type: string

# Never cancel an in-flight release — external publishes must be atomic.
concurrency:
Expand Down Expand Up @@ -53,21 +53,19 @@ jobs:
version: ${{ steps.classify.outputs.version }}
tag: ${{ steps.classify.outputs.tag }}
prerelease: ${{ steps.classify.outputs.prerelease }}
ref: ${{ steps.classify.outputs.ref }}
steps:
- name: Resolve tag
id: tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
TAG="$INPUT_TAG"
else
TAG="$REF_NAME"
if [[ "$REF" != refs/tags/* ]]; then
echo "::error::Release must run from a tag ref, not '$REF'. Rehearse with: gh workflow run release.yml --ref <tag>"
exit 1
fi
TAG="$REF_NAME"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then
echo "::error::Tag '$TAG' is not a valid semver release tag"
exit 1
Expand All @@ -85,12 +83,11 @@ jobs:
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"

# Always check out the tag ref, not the event SHA. Critical for
# workflow_dispatch where github.sha points at the branch HEAD.
# github.sha is the tag's commit for both triggers (the step
# above refuses a non-tag ref), so the default checkout is right.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ steps.tag.outputs.tag }}

- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable tip
with:
Expand Down Expand Up @@ -203,7 +200,6 @@ jobs:
echo "tag=$TAG"
echo "version=$VERSION"
echo "prerelease=$PRERELEASE"
echo "ref=$TAG"
} >> "$GITHUB_OUTPUT"

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down Expand Up @@ -243,7 +239,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}

- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable tip
with:
Expand Down Expand Up @@ -484,7 +479,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}

- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable tip
with:
Expand Down Expand Up @@ -569,7 +563,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}

- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable tip
with:
Expand Down Expand Up @@ -664,7 +657,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
Expand Down Expand Up @@ -1058,7 +1050,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}

- name: Gather all artefacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
Expand Down Expand Up @@ -1219,7 +1210,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-bundle
Expand Down Expand Up @@ -1408,7 +1398,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}

- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable tip
with:
Expand Down Expand Up @@ -1528,7 +1517,6 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.preflight.outputs.ref }}
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Download artefact + signature from published release
Expand Down
104 changes: 82 additions & 22 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
# `make dev-env-shell` (see docker/README.md). The Makefile passes the
# host UID/GID as build args so the mounted repo stays writable.

FROM ubuntu:noble
# Digest-pinned (OpenSSF Scorecard Pinned-Dependencies); Dependabot's
# `docker` entry in .github/dependabot.yml moves the digest.
FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517

# UID/GID for the in-container `dev` user. Defaults match the maintainer's
# host (2424); `make dev-env-build` overrides them with the caller's
Expand All @@ -42,6 +44,8 @@ ENV PATH=/usr/local/cargo/bin:/home/dev/.local/bin:/usr/local/bin:$PATH
# RUST_VERSION -> Cargo.toml workspace.package.rust-version
# NODE_MAJOR -> NodeSource line (>= 22.6 required by codegraph)
# RUMDL/…/ACTIONLINT -> .github/workflows/ci.yml lint job
# BINSTALL/UV -> no CI counterpart; bump by hand together with
# the per-arch SHA256 tables in their RUN blocks
ENV RUST_VERSION=1.94.0
ENV NODE_MAJOR=24
ENV RUMDL_VERSION=0.2.2
Expand All @@ -50,6 +54,8 @@ ENV SHFMT_VERSION=3.12.0
ENV SHELLCHECK_VERSION=0.10.0
ENV CHECKMAKE_VERSION=0.2.2
ENV ACTIONLINT_VERSION=1.7.12
ENV BINSTALL_VERSION=1.22.0
ENV UV_VERSION=0.12.7

# ---------------------------------------------------------------------------
# APT repositories: GitHub CLI + NodeSource. Keyrings are fetched and
Expand Down Expand Up @@ -134,6 +140,28 @@ RUN set -eux; \
rustup toolchain install nightly --profile minimal; \
rustup default "$RUST_VERSION"

# ---------------------------------------------------------------------------
# cargo-binstall — SHA256-verified release tarball rather than the
# upstream `curl | bash` installer, which resolves "latest" at build
# time and cannot be pinned. Upstream publishes no `.sha256` asset, so
# to bump: download both tarballs and `sha256sum` them.
# ---------------------------------------------------------------------------
RUN <<'EOF'
set -eux
ARCH=$(uname -m)
case "$ARCH" in
x86_64) SHA256="df2921254924066627685ee09f6c8b9acd7acef68a338668f879d2b033c09458" ;;
aarch64) SHA256="2fdf0ad20ffc7c7f94f32c19566f417d227484ff0335664d5df429e459d1354b" ;;
*) echo "Unsupported architecture: $ARCH" && exit 1 ;;
esac
wget --quiet -O /tmp/cargo-binstall.tgz "https://github.com/cargo-bins/cargo-binstall/releases/download/v${BINSTALL_VERSION}/cargo-binstall-${ARCH}-unknown-linux-musl.tgz"
echo "${SHA256} /tmp/cargo-binstall.tgz" | sha256sum --check
tar -xzf /tmp/cargo-binstall.tgz -C /tmp cargo-binstall
install -m 0755 /tmp/cargo-binstall "$CARGO_HOME/bin/cargo-binstall"
rm -f /tmp/cargo-binstall.tgz /tmp/cargo-binstall
cargo binstall --version
EOF

# Cargo dev/CI tools via cargo-binstall (prebuilt binaries — no long
# compiles). cargo-nextest / cargo-llvm-cov mirror CI; cargo-about /
# cargo-deny back `make release-check`; mdbook backs `make book`.
Expand All @@ -143,16 +171,23 @@ RUN set -eux; \
# cargo-about keeps its binary behind a non-default `cli` feature — so
# that fallback would install no binary, warn, and exit 0, baking a
# release image that cannot run `make release-check` (#1226).
#
# Every tool is version-pinned so a rebuild is reproducible. cargo-about
# tracks CARGO_ABOUT_VERSION in release.yml / python-cli-wheels.yml and
# the literal `cargo-about@…` in ci.yml's release-check job; mdbook
# tracks MDBOOK_VERSION in pages.yml (0.4.x is required by the
# mdbook-i18n-helpers pairing). No gate compares these copies, so a
# bump is a grep for the old version. The rest have no CI pin to
# mirror (ci.yml installs cargo-nextest / cargo-llvm-cov unpinned).
RUN set -eux; \
curl -fsSL https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash; \
cargo binstall --no-confirm \
cargo-udeps \
cargo-insta \
cargo-nextest \
cargo-llvm-cov \
cargo-about \
cargo-deny \
mdbook; \
cargo-udeps@0.1.61 \
cargo-insta@1.48.0 \
cargo-nextest@0.9.143 \
cargo-llvm-cov@0.9.0 \
cargo-about@0.8.4 \
cargo-deny@0.20.2 \
mdbook@0.4.40; \
cargo about --version; \
rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" /tmp/*

Expand Down Expand Up @@ -285,17 +320,36 @@ EOF
# tools into the system location (see UV_TOOL_* above); pyright ships as a
# Node package so it uses the system Node instead of downloading its own.
#
# These are deliberately unversioned, ruff included, even though this
# repository holds the ruff version in lockstep across four files
# (#1230). The image is built without the repository checked out, so it
# cannot read uv.lock; a literal version here would be a fifth copy to
# bump on every release, in a file no gate reads. `make py-fmt` /
# `py-lint` prefer `big-code-analysis-py/.venv/bin/ruff`, so once a
# uv itself is pinned (UV_VERSION + SHA256, from upstream's `.sha256`
# release assets) because it is a downloaded binary. The tools it
# installs — ruff, mypy, maturin — are deliberately unversioned, ruff
# included, even though this repository holds the ruff version in
# lockstep across four files (#1230). The image is built without the
# repository checked out, so it cannot read uv.lock; a literal version
# here would be a fifth copy to bump on every release, in a file no
# gate reads. `make py-fmt` / `py-lint` / `py-typecheck` prefer the
# binaries under `big-code-analysis-py/.venv/bin/`, so once a
# contributor runs `make py-bootstrap` inside the container the locked
# ruff wins over the one installed here.
# ruff / mypy / pyright win over the ones installed here (the npm
# pyright below is for editors and MCP servers, not the gate).
# ---------------------------------------------------------------------------
RUN <<'EOF'
set -eux
ARCH=$(uname -m)
case "$ARCH" in
x86_64) SHA256="788f18abea7c5f55d6216e4f5613fd89d4d59b631efeec117b2b07fe72f1da21" ;;
aarch64) SHA256="66393193038dd7eb108abd7a218d9cec04ac70ab98242b0720fa94de19223b7c" ;;
*) echo "Unsupported architecture: $ARCH" && exit 1 ;;
esac
wget --quiet -O /tmp/uv.tgz "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${ARCH}-unknown-linux-gnu.tar.gz"
echo "${SHA256} /tmp/uv.tgz" | sha256sum --check
tar -xzf /tmp/uv.tgz -C /tmp
install -m 0755 "/tmp/uv-${ARCH}-unknown-linux-gnu/uv" "/tmp/uv-${ARCH}-unknown-linux-gnu/uvx" /usr/local/bin/
rm -rf /tmp/uv.tgz "/tmp/uv-${ARCH}-unknown-linux-gnu"
uv --version
EOF

RUN set -eux; \
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh; \
mkdir -p "$UV_TOOL_DIR"; \
uv tool install --python 3.12 ruff; \
uv tool install --python 3.12 mypy; \
Expand All @@ -307,14 +361,20 @@ RUN set -eux; \
# survive the /home/dev volume: Claude Code, the two npx-launched MCP
# servers (codegraph, Context7), plus the Python/Bash language servers.
# codegraph builds better-sqlite3 natively here (build-essential + python3).
#
# Versions are pinned exactly so this layer is reproducible (the image
# as a whole is not: nightly rustup, the uv-installed Python tools, and
# the git-sourced Serena below float by design). A global install has
# no lockfile, so this is as far as npm pinning goes (Scorecard's rule
# wants `npm ci`, which cannot apply here).
# ---------------------------------------------------------------------------
RUN set -eux; \
npm install -g \
@anthropic-ai/claude-code \
@optave/codegraph \
@upstash/context7-mcp \
pyright \
bash-language-server; \
@anthropic-ai/claude-code@2.1.251 \
@optave/codegraph@3.17.0 \
@upstash/context7-mcp@4.0.4 \
pyright@1.1.413 \
bash-language-server@5.6.0; \
npm cache clean --force; \
rm -rf /root/.npm /tmp/*

Expand Down
22 changes: 18 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,18 @@ Signed artefacts, SBOMs, and SLSA provenance still publish normally,
so a pre-release is a full test of everything except the external
pushes.

To re-run the release workflow against a tag that already exists,
dispatch it *from* the tag — `gh workflow run release.yml --ref
v0.0.0-test1`, or pick the tag under "Use workflow from" in the
Actions tab. There is no tag input: preflight refuses a dispatch from
a branch, so the checked-out commit is always the tag's. A dispatch
runs the workflow file *at that tag*, so tags cut before this change
(`v2.1.0` and earlier) still carry the old required `tag` input and
need `-f tag=<tag>` as well. The flip side: a fix to `release.yml`
itself cannot be rehearsed against an existing tag, because the
dispatch runs the tag's copy of the file — cut a fresh `v0.0.0-testN`
on the commit that carries the fix instead.

Both PyPI wheel workflows still **build and smoke-test** every wheel on
a pre-release tag but **skip the PyPI publish step**, so a pre-release
never lands a wheel on PyPI. The CLI wheel (`python-cli-wheels.yml`)
Expand Down Expand Up @@ -1076,10 +1088,12 @@ above are therefore sufficient: there is no separate

### Testing a release candidate without uploading

`workflow_dispatch` from the **Actions** tab runs the full build +
smoke-test matrix without invoking the publish job (the `if:`
guard requires a `v*` tag push). Use this to validate a
release-prep branch before tagging.
For the two PyPI wheel workflows, `workflow_dispatch` from the
**Actions** tab runs the full build + smoke-test matrix without
invoking the publish job (the `if:` guard requires a `v*` tag push).
Use this to validate a release-prep branch before tagging. This does
not apply to `release.yml`, whose preflight refuses a branch dispatch
— rehearse it from a pre-release tag as described above.

To exercise the PyPI side end-to-end against
`https://test.pypi.org/`, temporarily change the
Expand Down