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
55 changes: 51 additions & 4 deletions .github/workflows/qt-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,44 @@ jobs:
# The Qt install itself is cached separately and by key, below.
enable-cache: false

# The toolchain is installed from a hash-locked closure, not resolved at
# run time. `uvx --from 'aqtinstall@3.3.0' --with 'py7zr==1.0.0'` pinned two
# names and left everything they pull unbounded -- aqtinstall declares
# bs4, defusedxml, humanize, patch-ng, semantic-version and texttable with
# no upper bound -- so two runs of the same workflow SHA could install
# different code without this repository changing. That mattered here more
# than most places: driving aqtinstall directly was itself a supply-chain
# decision, taken to escape an action whose nested graph could not be pinned.
#
# This is a reusable workflow, so the checkout above is the *caller's* tree
# and the lock is not in it. It is fetched from the exact commit of this
# workflow file -- `job.workflow_sha` is the runner's own answer to "what
# is executing", which the caller cannot forge -- and its digest is checked
# against the value recorded here before anything is installed. Pinning the
# workflow therefore pins the closure, and `--require-hashes` means even a
# substituted lock cannot introduce a package.
- name: Provision the locked Qt toolchain
id: toolchain
env:
CALLEE_REPOSITORY: ${{ job.workflow_repository }}
CALLEE_SHA: ${{ job.workflow_sha }}
LOCK_SHA256: 56499b7af8bbd983954cf15cf51ee97ff6915332d3716ed53a1102fa60449cd0
PYTHON_VERSION: '3.13'
run: |
set -euo pipefail
lock="$RUNNER_TEMP/requirements-qt.txt"
curl --fail --silent --show-error --location --retry 3 \
--output "$lock" \
"https://raw.githubusercontent.com/$CALLEE_REPOSITORY/$CALLEE_SHA/requirements-qt.txt"
printf '%s %s\n' "$LOCK_SHA256" "$lock" | sha256sum --check --status || {
echo "::error::the fetched Qt toolchain lock does not match the digest this workflow was published with"
exit 1
}
uv venv --python "$PYTHON_VERSION" "$RUNNER_TEMP/qt-tools"
uv pip install --python "$RUNNER_TEMP/qt-tools" --require-hashes -r "$lock"
printf 'bin=%s\n' "$RUNNER_TEMP/qt-tools/bin" >> "$GITHUB_OUTPUT"
printf 'lock_sha256=%s\n' "$LOCK_SHA256" >> "$GITHUB_OUTPUT"

- name: Resolve the Qt release
id: release
working-directory: ${{ github.workspace }}
Expand All @@ -118,13 +156,14 @@ jobs:
QT_MODULES: ${{ inputs.qt_modules }}
QT_TARGET: ${{ inputs.qt_target }}
QT_VERSION: ${{ inputs.qt_version }}
TOOLCHAIN_BIN: ${{ steps.toolchain.outputs.bin }}
run: |
set -euo pipefail
# aqt and py7zr are pinned exactly. They are this workflow's own tool
# versions, and `check_sdk_runtime_fixtures` compares the aqt identity
# in the receipt against the fixture spec, so the two cannot drift
# apart unnoticed.
aqt() { uvx --from 'aqtinstall@3.3.0' --with 'py7zr==1.0.0' aqt "$@"; }
aqt() { "$TOOLCHAIN_BIN/aqt" "$@"; }

host="$QT_HOST"
if [ -z "$host" ]; then
Expand Down Expand Up @@ -202,6 +241,7 @@ jobs:
QT_MODULES: ${{ inputs.qt_modules }}
QT_TARGET: ${{ inputs.qt_target }}
QT_VERSION: ${{ steps.release.outputs.version }}
TOOLCHAIN_BIN: ${{ steps.toolchain.outputs.bin }}
run: |
set -euo pipefail
# `qt_modules` is documented as space-separated, so it is read into an
Expand All @@ -211,7 +251,7 @@ jobs:
read -r -a modules <<< "$QT_MODULES"
modules=(-m "${modules[@]}")
fi
uvx --from 'aqtinstall@3.3.0' --with 'py7zr==1.0.0' aqt install-qt \
"$TOOLCHAIN_BIN/aqt" install-qt \
"$QT_HOST" "$QT_TARGET" "$QT_VERSION" "$QT_ARCH" \
"${modules[@]}" \
--outputdir "${RUNNER_TEMP}/qt"
Expand Down Expand Up @@ -282,6 +322,7 @@ jobs:
CALLEE_REPOSITORY: ${{ job.workflow_repository }}
CALLEE_SHA: ${{ job.workflow_sha }}
AQT_VERSION: ${{ steps.release.outputs.aqt_version }}
TOOLCHAIN_LOCK_SHA256: ${{ steps.toolchain.outputs.lock_sha256 }}
CONFIGURE_COMMAND: ${{ inputs.configure_command }}
QT_VERSION_INPUT: ${{ inputs.qt_version }}
TEST_COMMAND: ${{ inputs.test_command }}
Expand Down Expand Up @@ -318,10 +359,16 @@ jobs:
sections = ["toolchain"]
receipt["cache_key_prefix"] = "qt-ci-v1"
receipt["qt_version_input"] = os.environ["QT_VERSION_INPUT"]
# Resolved in the install step: `aqt` is invoked through `uvx` and is
# not on PATH here, and it reports its identity on stderr.
# Resolved in the install step: `aqt` lives in the provisioned
# toolchain rather than on PATH here, and reports its identity on
# stderr. The lock digest goes in beside it, so the receipt records the
# whole executable closure rather than one package's version -- which
# was the gap: `aqt 3.3.0` said nothing about the twenty-six packages
# underneath it.
if os.environ.get("AQT_VERSION"):
receipt["aqt_version"] = os.environ["AQT_VERSION"]
if os.environ.get("TOOLCHAIN_LOCK_SHA256"):
receipt["toolchain_lock_sha256"] = os.environ["TOOLCHAIN_LOCK_SHA256"]
cmake = tool(["cmake", "--version"])
if cmake is not None:
receipt["cmake_version"] = cmake
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

## [Unreleased]

- Close the Qt toolchain's dependency graph. `qt-ci.yml` ran
`uvx --from 'aqtinstall@3.3.0' --with 'py7zr==1.0.0'`, which pinned two names
and left everything they pull unbounded: aqtinstall declares `bs4`,
`defusedxml`, `humanize`, `patch-ng`, `semantic-version` and `texttable` with no
upper bound, plus `requests>=2.31.0`. Two runs of the same workflow SHA could
install different code without this repository changing. That mattered here more
than most places, because driving aqtinstall directly was itself a supply-chain
decision — taken to escape an action whose nested graph could not be pinned.
Escaping one unpinned graph into another is not an improvement.

`requirements-qt.txt` is the closure now: twenty-eight packages, all with
hashes, installed with `--require-hashes` into an isolated environment.

`qt-ci.yml` is a reusable workflow, so the checkout it runs in belongs to the
caller and the lock is not there. It is fetched from the exact commit of the
workflow file — `job.workflow_sha` is the runner's own answer to what is
executing, which a caller cannot forge — and its digest is checked against a
value published in that file before anything installs. Pinning the workflow
therefore pins the closure, and `--require-hashes` means even a substituted lock
cannot introduce a package.

`catalog/tools.yml` registers both tools and the lock digest;
`check_qt_toolchain_lock.py` holds the three statements together — the digest
the workflow publishes and the lock in the tree, the versions the lock pins and
the versions the catalog records, and that every requirement carries hashes at
all. The runtime receipt records `toolchain_lock_sha256` beside `aqt_version`,
because `aqt 3.3.0` said nothing about the twenty-six packages underneath it.

Re-proven by a real fixture run before the ledger was updated.

- Make the consumer skill runnable, execute what `.gds` declares, and stop
shipping a build log.

Expand Down
7 changes: 4 additions & 3 deletions catalog/python-execution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schema_version": 1,
"python": {
"major_minor": "3.13",
"subject_count": 62,
"subject_count": 63,
"launcher": "scripts/check_python_execution_contract.py",
"launcher_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_execution_contract.py", "--launch"],
"syntax_gate_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_syntax.py"],
Expand Down Expand Up @@ -127,7 +127,7 @@
"check_validation_tier_contract.py",
"check_docs_links.py", "check_documented_commands.py",
"check_flutter_pin.py", "check_gds_verification_commands.py",
"check_qt_pin.py",
"check_qt_pin.py", "check_qt_toolchain_lock.py",
"check_examples.py",
"check_gate_contract.py", "check_harden_runner_contract.py",
"check_merge_group.py", "check_monorepo_routing.py",
Expand Down Expand Up @@ -173,6 +173,7 @@
"check_flutter_pin.py": ["_strict_yaml"],
"check_gds_verification_commands.py": ["_strict_yaml", "check_python_execution_contract"],
"check_qt_pin.py": ["_strict_yaml"],
"check_qt_toolchain_lock.py": ["_strict_yaml", "_workflow_yaml"],
"check_examples.py": ["_runners", "_workflow_yaml"],
"check_gate_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"],
"check_harden_runner_contract.py": ["_workflow_yaml"],
Expand Down Expand Up @@ -213,7 +214,7 @@
"check_maintenance_report_contract",
"check_validation_tier_contract",
"check_docs_links", "check_documented_commands", "check_examples",
"check_flutter_pin", "check_qt_pin",
"check_flutter_pin", "check_qt_pin", "check_qt_toolchain_lock",
"check_gate_contract", "check_gds_verification_commands",
"check_harden_runner_contract", "check_merge_group",
"check_monorepo_routing", "check_permissions", "check_pinned_actions",
Expand Down
4 changes: 2 additions & 2 deletions catalog/runtime-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ entries:
criticality: supporting
status: runtime-proven
evidence: "Fixture caller ran the default configure/build/test lanes against tests/fixtures/qt on ubuntu-latest and the observer accepted the receipt: four sections (build, configure, test, toolchain), one CTest test, Qt 6.8.3 resolved and installed, and aqtinstall(aqt) v3.3.0. Qt is installed by a pinned aqtinstall invoked directly, so the workflow reaches no action it does not pin and starts where full-SHA pinning is enforced; check_transitive_action_pins reports the tree clean. The version resolves to exactly one published release and the architecture to exactly one published architecture, both refusing to guess, and the install root is located by its own qmake rather than derived from the architecture name. Linux only: only ubuntu-latest has been run."
last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31853477977
proven_digest: 1b3fc160002e1960e567599a71ebd2cca677bf54c7abe0359f86a02e74ac1f0c
last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31855694582
proven_digest: 86abe2691551ed32d11364856e553460b7dbfd487082951aaa2878ca2da79bfd
waiver: null
- workflow: .github/workflows/r-ci.yml
criticality: supporting
Expand Down
27 changes: 27 additions & 0 deletions catalog/tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@
# Last verified: 2026-08-13

tools:
- id: aqtinstall
name: aqtinstall
homepage: https://github.com/miurahr/aqtinstall
kind: cli
current_version: "3.3.0"
pin: "3.3.0"
used_by:
- .github/workflows/qt-ci.yml
last_verified: "2026-08-15"
notes: >-
Installed from requirements-qt.txt, a hash-locked closure of 28 packages,
rather than resolved at run time. Pinning the two top-level names left
everything they pull unbounded.
lock: requirements-qt.txt
lock_sha256: "56499b7af8bbd983954cf15cf51ee97ff6915332d3716ed53a1102fa60449cd0"
- id: py7zr
name: py7zr
homepage: https://github.com/miurahr/py7zr
kind: cli
current_version: "1.0.0"
pin: "1.0.0"
used_by:
- .github/workflows/qt-ci.yml
last_verified: "2026-08-15"
notes: aqtinstall's archive backend; locked in the same closure.
lock: requirements-qt.txt
lock_sha256: "56499b7af8bbd983954cf15cf51ee97ff6915332d3716ed53a1102fa60449cd0"
- id: actionlint
name: actionlint
homepage: "https://github.com/rhysd/actionlint"
Expand Down
12 changes: 12 additions & 0 deletions requirements-qt.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# The Qt toolchain `qt-ci.yml` executes.
#
# `uvx --from 'aqtinstall@3.3.0' --with 'py7zr==1.0.0'` pinned two names and left
# everything they pull unbounded: aqtinstall declares bs4, defusedxml, humanize,
# patch-ng, semantic-version and texttable with no upper bound, plus
# requests>=2.31.0. Two runs of the same workflow SHA could install different
# code without this repository changing. Compile with:
#
# uv pip compile --generate-hashes --universal --python-version 3.13 \
# -o requirements-qt.txt requirements-qt.in
aqtinstall==3.3.0
py7zr==1.0.0
Loading
Loading