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
95 changes: 73 additions & 22 deletions .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,37 @@ name: Dependabot auto-merge
# triggers on `main` and folds the security bump into the SAME staging
# branch — nothing is ever left sitting on `main` waiting for a human.
#
# Merge strategy: the incoming branch ALWAYS clobbers what is already staged
# (`git merge -X theirs`). Successive bumps of the same lock-file never conflict-
# stall: the latest bump wins, every time. Nothing reaches `main` this way — the
# full build/test (ci.yml) + CodeQL (codeql.yml) gate the single
# `dependabot-upgrades -> main` consolidation PR, which is where review and the
# expensive matrix actually run. ci.yml/codeql.yml deliberately SKIP Dependabot
# PRs (they would only burn the matrix on a bump we immediately sweep away).
# Sweep strategy: PATH-RESTRICTED, not a branch merge. Only the files the PR
# itself changed (diff against its merge-base with the PR base) are copied from
# the bump branch onto the staging tip. Two reasons:
#
# * The GITHUB_TOKEN can NEVER push changes to `.github/workflows/*` — the
# `workflows` scope cannot be granted to it, not even via `permissions:`.
# A full `git merge` of a security bump (based on current `main`) into a
# staging branch that has drifted behind `main` drags main's newer commits
# along — including workflow-file edits — and the push is rejected
# (`refusing to allow a GitHub App to ... update workflow`). Copying only
# the bump's own files makes the sweep immune to staging-branch drift.
# * Clobber semantics survive: the latest bump overwrites the same manifest/
# lock files, so successive bumps never conflict-stall — no merge machinery
# left to conflict at all.
#
# If the bump ITSELF touches `.github/workflows/*` (a github-actions SECURITY
# update — version updates of actions target the staging branch and merge on
# GitHub's side, not here), the token cannot stage it by construction. The
# sweep then leaves the PR open with a comment instead of failing: an actions
# security bump warrants human eyes anyway.
#
# Nothing reaches `main` this way — the full build/test (ci.yml) + CodeQL
# (codeql.yml) gate the single `dependabot-upgrades -> main` consolidation PR,
# which is where review and the expensive matrix actually run. ci.yml/codeql.yml
# deliberately SKIP Dependabot PRs (they would only burn the matrix on a bump we
# immediately sweep away).
#
# Lives at the repo root so it is present on `dependabot-upgrades` (cut from
# main): for `pull_request` the workflow is read from the PR's base branch, so
# BOTH `main` and the staging branch must carry this file.
# BOTH `main` and the staging branch must carry this file. Actions cannot push
# workflow files, so updating the staging branch's copy is always a human push.
#
# ⚠️ SECURITY INVARIANT — the trigger MUST stay `pull_request`, NEVER
# `pull_request_target`. Under `pull_request` a fork PR runs with a READ-ONLY
Expand All @@ -42,7 +62,7 @@ permissions:

jobs:
sweep:
name: Clobber-merge into dependabot-upgrades
name: Sweep bump files into dependabot-upgrades
# Two independent gates, both required: the (unforgeable) Dependabot actor
# AND a `dependabot/*` source branch. actor-AND-source keeps the trust
# assumption explicit and resilient if the trigger set is ever widened.
Expand All @@ -58,34 +78,65 @@ jobs:
ref: dependabot-upgrades
fetch-depth: 0

- name: Clobber-merge the bump and retire the PR
- name: Sweep the bump's files and retire the PR
env:
PR_URL: ${{ github.event.pull_request.html_url }}
PR_HEAD: ${{ github.event.pull_request.head.ref }}
PR_BASE: ${{ github.event.pull_request.base.ref }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Defense-in-depth: disable git hooks for every git op in this job, so
# merging attacker-influenced tree content can never execute a hook by
# staging attacker-influenced tree content can never execute a hook by
# construction. No hook is reachable today (hooks live in .git, not the
# tree), but this holds regardless of any future change.
git config --global core.hooksPath /dev/null
# Pull the bump branch into a stable local ref we can re-merge.
git fetch origin "+refs/heads/${PR_HEAD}:refs/remotes/origin/${PR_HEAD}"
# Re-merge onto the LIVE staging tip and retry: concurrent Dependabot
# PRs race to push here, so each run rebases on whatever already landed
# and the incoming branch always wins conflicts (-X theirs).
# Pull the bump branch and its base into stable local refs.
git fetch origin \
"+refs/heads/${PR_HEAD}:refs/remotes/origin/${PR_HEAD}" \
"+refs/heads/${PR_BASE}:refs/remotes/origin/${PR_BASE}"
# The PR's OWN changes: diff against the merge-base with its base
# branch, so nothing the base has accumulated rides along.
merge_base="$(git merge-base "origin/${PR_BASE}" "origin/${PR_HEAD}")"
changed_list="${RUNNER_TEMP}/dependabot-sweep-paths.z"
git diff -z --name-only "${merge_base}" "origin/${PR_HEAD}" \
> "${changed_list}"
# The token cannot push workflow files at all; hand those to a human.
while IFS= read -r -d '' path; do
case "${path}" in
.github/workflows/*)
echo "::warning::bump edits ${path}; GITHUB_TOKEN cannot push workflow files — leaving the PR open"
gh pr comment "${PR_URL}" --body \
"Not swept: this bump edits \`${path}\`, which the Actions token cannot push (no \`workflows\` scope). Please review and merge manually."
exit 0
;;
esac
done < "${changed_list}"
# Re-apply onto the LIVE staging tip and retry: concurrent Dependabot
# PRs race to push here, so each run restarts from whatever already
# landed. Copying (not merging) the bump's paths keeps clobber
# semantics: the latest bump of a file wins, every time.
for attempt in 1 2 3 4 5; do
git fetch origin "+refs/heads/dependabot-upgrades:refs/remotes/origin/dependabot-upgrades"
git reset --hard "origin/dependabot-upgrades"
git merge -X theirs --no-edit "origin/${PR_HEAD}" \
-m "build(deps): clobber-merge ${PR_HEAD} into dependabot-upgrades"
while IFS= read -r -d '' path; do
if git cat-file -e "origin/${PR_HEAD}:${path}" 2>/dev/null; then
git checkout "origin/${PR_HEAD}" -- "${path}"
else
git rm -q --ignore-unmatch -- "${path}"
fi
done < "${changed_list}"
if git diff --cached --quiet; then
echo "bump already staged on dependabot-upgrades; nothing to commit"
break
fi
git commit -q -m "build(deps): sweep ${PR_HEAD} into dependabot-upgrades"
if git push origin "HEAD:dependabot-upgrades"; then
break
fi
if [ "$attempt" = "5" ]; then
if [ "${attempt}" = "5" ]; then
echo "::error::could not push to dependabot-upgrades after 5 attempts"
exit 1
fi
Expand All @@ -94,6 +145,6 @@ jobs:
# Retire the PR + its branch: the bump is already staged, so the PR
# (whether it targeted main or the staging branch) has served its
# purpose. `|| true` — GitHub may have auto-closed it on the push.
gh pr close "$PR_URL" --delete-branch \
--comment "Swept into \`dependabot-upgrades\` (latest bump clobbers previous)." \
|| git push origin --delete "$PR_HEAD" || true
gh pr close "${PR_URL}" --delete-branch \
--comment "Swept into \`dependabot-upgrades\` (latest bump of a file clobbers the previous one)." \
|| git push origin --delete "${PR_HEAD}" || true
65 changes: 40 additions & 25 deletions crates/basilisk-checker/src/exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,34 +387,49 @@ pub fn load_snapshot_stub_module(
source_text,
request,
target,
|module_name| {
let (logical_uri, body) = match target {
Some(target) => snapshot.read_stub_for_target(module_name, target.python_version),
None => snapshot.read_stub(module_name),
}?;
match target {
Some(target) => basilisk_stubs::pyi_parser::parse_pyi_source_for_target(
body,
Path::new(&logical_uri),
module_name,
stub_source,
basilisk_stubs::StubTier::Tier1,
target,
)
.ok(),
None => basilisk_stubs::parse_pyi_source(
body,
Path::new(&logical_uri),
module_name,
stub_source,
basilisk_stubs::StubTier::Tier1,
)
.ok(),
}
},
|module_name| parse_snapshot_stub(snapshot, target, module_name, stub_source),
)
}

/// Read one module's stub body out of an active snapshot and parse it.
///
/// The single producer of a snapshot-backed [`StubModule`], shared by the
/// cross-module loader above and by the user-stub re-export fallback
/// ([STUBRES-PYI-REEXPORTS], GitHub #312 follow-up): a user stub's star
/// re-export from a stdlib module must see the same target-filtered parse the
/// snapshot's own modules get.
#[must_use]
pub fn parse_snapshot_stub(
snapshot: &basilisk_stubs::typeshed::snapshot::Snapshot,
target: Option<&StubTarget>,
module_name: &str,
stub_source: StubSource,
) -> Option<StubModule> {
let (logical_uri, body) = match target {
Some(target) => snapshot.read_stub_for_target(module_name, target.python_version),
None => snapshot.read_stub(module_name),
}?;
match target {
Some(target) => basilisk_stubs::pyi_parser::parse_pyi_source_for_target(
body,
Path::new(&logical_uri),
module_name,
stub_source,
StubTier::Tier1,
target,
)
.ok(),
None => basilisk_stubs::parse_pyi_source(
body,
Path::new(&logical_uri),
module_name,
stub_source,
StubTier::Tier1,
)
.ok(),
}
}

/// Load a `.pyi` external module from immutable VFS text rather than disk.
/// Non-stub requests are rejected because `py.typed` modules remain filesystem
/// sources at resolution step 5.
Expand Down
32 changes: 29 additions & 3 deletions crates/basilisk-checker/src/imports/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ fn capture_user_stub_api(
tier,
)
.ok()?;
Some((import.module.clone(), build_stub_api(&stub, stub_path)))
Some((
import.module.clone(),
build_stub_api(&stub, stub_path, search_paths),
))
}

/// Capture the member API of a plain `import X` backed by the **active step-3
Expand Down Expand Up @@ -322,7 +325,10 @@ pub fn recapture_user_stub_from_source(
tier,
)
.ok()?;
Some((import.module.clone(), build_stub_api(&stub, stub_path)))
Some((
import.module.clone(),
build_stub_api(&stub, stub_path, search_paths),
))
}

/// Whether this import binds a user stub — i.e. its member API is re-derived
Expand Down Expand Up @@ -363,16 +369,36 @@ fn user_stub_path<'a>(
/// presence) from a parsed user stub. Names the stub re-exports — redundant
/// aliases, `__all__` entries, and star-imported submodules' export sets
/// ([STUBRES-PYI-REEXPORTS], GitHub #312) — are members too.
///
/// Star targets that don't resolve beside the stub fall back to the active
/// step-3 Typeshed source: `MicroPython`'s `uio.pyi` is just `from io import *`
/// with `io` in the custom typeshed's `stdlib/` tree (GitHub #312 follow-up).
fn build_stub_api(
stub: &basilisk_stubs::StubModule,
stub_path: &std::path::Path,
search_paths: &ImportSearchPaths,
) -> ImportedModuleApi {
let mut member_names = std::collections::HashSet::new();
member_names.extend(stub.functions.keys().cloned());
member_names.extend(stub.classes.keys().cloned());
member_names.extend(stub.variables.keys().cloned());
member_names.extend(stub.overloads.keys().cloned());
member_names.extend(basilisk_stubs::reexported_member_names(stub));
let mut snapshot_fallback = |module_name: &str| {
let active = search_paths.typeshed_snapshot.as_ref()?;
let (snapshot, target) = active.for_importer(Some(stub_path))?;
crate::exports::parse_snapshot_stub(
snapshot,
target,
module_name,
snapshot_stub_source(snapshot),
)
};
member_names.extend(
basilisk_stubs::reexports::reexported_member_names_with_fallback(
stub,
&mut snapshot_fallback,
),
);
ImportedModuleApi {
member_names,
has_getattr: stub.functions.contains_key("__getattr__"),
Expand Down
Loading
Loading