From 9569f8ca1cd26b09e170cd23bb4cf7794717a510 Mon Sep 17 00:00:00 2001 From: don-petry <36422719+don-petry@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:14:41 -0700 Subject: [PATCH 1/5] chore: add Dependabot security-only update config (#1) Adds Dependabot security-only config, auto-merge workflow, and dependency audit CI. --- .github/dependabot.yml | 19 ++ .github/workflows/dependabot-automerge.yml | 77 ++++++++ .github/workflows/dependency-audit.yml | 217 +++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-automerge.yml create mode 100644 .github/workflows/dependency-audit.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4012cb0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "security" + - "dependencies" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "security" + - "dependencies" diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..aa27ff0 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,77 @@ +# Dependabot auto-merge workflow +# +# Requires repository secrets: +# APP_ID — GitHub App ID with contents:write and pull-requests:write +# APP_PRIVATE_KEY — GitHub App private key +# +# Auto-approves and enables auto-merge for Dependabot PRs that are: +# - GitHub Actions updates (patch or minor version bumps) +# - Security updates for any ecosystem (patch or minor) +# - Indirect (transitive) dependency updates +# Major version updates are always left for human review. +# Uses --auto so the merge waits for all required CI checks to pass. +# +# Safety model: application ecosystems use open-pull-requests-limit: 0 in +# dependabot.yml, so the only app-ecosystem PRs Dependabot can create are +# security updates. This workflow adds defense-in-depth by also checking +# the package ecosystem. +name: Dependabot auto-merge + +on: + pull_request_target: + branches: + - main + +permissions: {} + +jobs: + dependabot: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + if: github.event.pull_request.user.login == 'dependabot[bot]' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Determine if auto-merge eligible + id: eligible + run: | + UPDATE_TYPE="${{ steps.metadata.outputs.update-type }}" + DEP_TYPE="${{ steps.metadata.outputs.dependency-type }}" + ECOSYSTEM="${{ steps.metadata.outputs.package-ecosystem }}" + + # Must be patch, minor, or indirect + if [[ "$UPDATE_TYPE" != "version-update:semver-patch" && \ + "$UPDATE_TYPE" != "version-update:semver-minor" && \ + "$DEP_TYPE" != "indirect" ]]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + echo "Skipping: major update requires human review" + exit 0 + fi + + # GitHub Actions version updates are always eligible + # App ecosystem PRs can only exist as security updates (limit: 0) + echo "eligible=true" >> "$GITHUB_OUTPUT" + echo "Auto-merge eligible: ecosystem=$ECOSYSTEM update=$UPDATE_TYPE" + + - name: Generate app token + if: steps.eligible.outputs.eligible == 'true' + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Approve and enable auto-merge + if: steps.eligible.outputs.eligible == 'true' + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..943ba65 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,217 @@ +# Dependency vulnerability audit +# +# Auto-detects ecosystems present in the repository and runs the appropriate +# audit tool. Fails the build if any dependency has a known security advisory. +# +# Add "dependency-audit" as a required status check in branch protection. +# +# Pinned tool versions (update deliberately): +# govulncheck v1.1.4 | cargo-audit 0.22.1 | pip-audit 2.9.0 +name: Dependency audit + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + detect: + name: Detect ecosystems + runs-on: ubuntu-latest + outputs: + npm: ${{ steps.check.outputs.npm }} + pnpm: ${{ steps.check.outputs.pnpm }} + gomod: ${{ steps.check.outputs.gomod }} + cargo: ${{ steps.check.outputs.cargo }} + pip: ${{ steps.check.outputs.pip }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Detect package ecosystems + id: check + run: | + # npm — look for package-lock.json anywhere (excluding node_modules) + if find . -name 'package-lock.json' -not -path '*/node_modules/*' | grep -q .; then + echo "npm=true" >> "$GITHUB_OUTPUT" + else + echo "npm=false" >> "$GITHUB_OUTPUT" + fi + + # pnpm — look for pnpm-lock.yaml anywhere + if find . -name 'pnpm-lock.yaml' -not -path '*/node_modules/*' | grep -q .; then + echo "pnpm=true" >> "$GITHUB_OUTPUT" + else + echo "pnpm=false" >> "$GITHUB_OUTPUT" + fi + + # Go modules — detect via go.mod (not go.sum, which may not exist) + if find . -name 'go.mod' -not -path '*/vendor/*' | grep -q .; then + echo "gomod=true" >> "$GITHUB_OUTPUT" + else + echo "gomod=false" >> "$GITHUB_OUTPUT" + fi + + # Cargo — detect via Cargo.toml anywhere (lockfile may not exist for libraries) + if find . -name 'Cargo.toml' -not -path '*/target/*' | grep -q .; then + echo "cargo=true" >> "$GITHUB_OUTPUT" + else + echo "cargo=false" >> "$GITHUB_OUTPUT" + fi + + # Python — detect pyproject.toml or requirements.txt anywhere + if find . -name 'pyproject.toml' -not -path '*/.venv/*' -not -path '*/venv/*' | grep -q . || \ + find . -name 'requirements.txt' -not -path '*/.venv/*' -not -path '*/venv/*' | grep -q .; then + echo "pip=true" >> "$GITHUB_OUTPUT" + else + echo "pip=false" >> "$GITHUB_OUTPUT" + fi + + audit-npm: + name: npm audit + needs: detect + if: needs.detect.outputs.npm == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "lts/*" + + - name: Audit npm dependencies + run: | + # Audit each package-lock.json found in the repo + status=0 + while IFS= read -r dir; do + echo "::group::npm audit $dir" + if ! (cd "$dir" && npm audit --audit-level=low); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'package-lock.json' -not -path '*/node_modules/*' -exec dirname {} \;) + exit $status + + audit-pnpm: + name: pnpm audit + needs: detect + if: needs.detect.outputs.pnpm == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "lts/*" + cache: "pnpm" + + - name: Audit pnpm dependencies + run: | + # Audit each pnpm-lock.yaml found in the repo + status=0 + while IFS= read -r dir; do + echo "::group::pnpm audit $dir" + if ! (cd "$dir" && pnpm audit --audit-level low); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'pnpm-lock.yaml' -not -path '*/node_modules/*' -exec dirname {} \;) + exit $status + + audit-go: + name: govulncheck + needs: detect + if: needs.detect.outputs.gomod == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: "stable" + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + + - name: Audit Go dependencies + run: | + status=0 + while IFS= read -r dir; do + echo "::group::govulncheck $dir" + if ! (cd "$dir" && govulncheck ./...); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'go.mod' -not -path '*/vendor/*' -exec dirname {} \;) + exit $status + + audit-cargo: + name: cargo audit + needs: detect + if: needs.detect.outputs.cargo == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit@0.22.1 --locked + + - name: Audit Cargo dependencies + run: | + # cargo audit operates on Cargo.lock at workspace root + # For workspaces, a single audit at root covers all crates + status=0 + while IFS= read -r dir; do + echo "::group::cargo audit $dir" + if ! (cd "$dir" && cargo generate-lockfile 2>/dev/null; cargo audit); then + status=1 + fi + echo "::endgroup::" + done < <(find . -name 'Cargo.toml' -not -path '*/target/*' -exec dirname {} \; | sort -u) + exit $status + + audit-pip: + name: pip-audit + needs: detect + if: needs.detect.outputs.pip == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.x" + + - name: Install pip-audit + run: pip install pip-audit==2.9.0 + + - name: Audit Python dependencies + run: | + status=0 + # Audit each Python project found in the repo + while IFS= read -r dir; do + echo "::group::pip-audit $dir" + if [ -f "$dir/pyproject.toml" ]; then + if ! pip-audit "$dir"; then + status=1 + fi + elif [ -f "$dir/requirements.txt" ]; then + if ! pip-audit -r "$dir/requirements.txt"; then + status=1 + fi + fi + echo "::endgroup::" + done < <( + { + find . -name 'pyproject.toml' -not -path '*/.venv/*' -not -path '*/venv/*' -exec dirname {} \; + find . -name 'requirements.txt' -not -path '*/.venv/*' -not -path '*/venv/*' -exec dirname {} \; + } | sort -u + ) + exit $status From b82028fbdb5885d61c74fbd43b0d97dd18827c53 Mon Sep 17 00:00:00 2001 From: DJ Date: Mon, 30 Mar 2026 19:20:49 -0700 Subject: [PATCH 2/5] feat: add --abort flag to dgr sync to cancel paused restack operations When a restack pauses on a conflict, users had to manually run git rebase --abort and clean up operation.json. This adds dgr sync --abort which safely aborts any in-progress git rebase and clears the pending operation state. The flag is mutually exclusive with --continue via clap's conflicts_with. The pending operation error message now mentions --abort as an option alongside --continue. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/sync/mod.rs | 27 ++++++++++++++++++++++++++- src/core/git.rs | 8 ++++++++ src/core/sync.rs | 32 ++++++++++++++++++++++++++++++++ src/core/workflow.rs | 2 +- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli/sync/mod.rs b/src/cli/sync/mod.rs index 56da08a..d2eb188 100644 --- a/src/cli/sync/mod.rs +++ b/src/cli/sync/mod.rs @@ -19,8 +19,12 @@ use super::common; #[derive(Args, Debug, Clone, Default)] pub struct SyncArgs { /// Continue a paused restack rebase sequence - #[arg(short = 'c', long = "continue")] + #[arg(short = 'c', long = "continue", conflicts_with = "abort_operation")] pub continue_operation: bool, + + /// Abort a paused restack rebase sequence + #[arg(long = "abort", conflicts_with = "continue_operation")] + pub abort_operation: bool, } pub fn execute(args: SyncArgs) -> io::Result { @@ -261,6 +265,13 @@ pub fn execute(args: SyncArgs) -> io::Result { } } + if args.abort_operation && outcome.status.success() { + println!("Aborted paused restack operation."); + return Ok(CommandOutcome { + status: outcome.status, + }); + } + if !outcome.status.success() { if outcome.paused { common::print_restack_pause_guidance(outcome.failure_output.as_deref()); @@ -434,6 +445,7 @@ impl From for SyncOptions { fn from(args: SyncArgs) -> Self { Self { continue_operation: args.continue_operation, + abort_operation: args.abort_operation, } } } @@ -550,8 +562,21 @@ mod tests { fn converts_cli_args_into_core_sync_options() { let options = SyncOptions::from(SyncArgs { continue_operation: true, + abort_operation: false, }); assert!(options.continue_operation); + assert!(!options.abort_operation); + } + + #[test] + fn converts_abort_cli_args_into_core_sync_options() { + let options = SyncOptions::from(SyncArgs { + continue_operation: false, + abort_operation: true, + }); + + assert!(!options.continue_operation); + assert!(options.abort_operation); } } diff --git a/src/core/git.rs b/src/core/git.rs index 5be1336..c3257ec 100644 --- a/src/core/git.rs +++ b/src/core/git.rs @@ -234,6 +234,14 @@ pub fn continue_rebase() -> io::Result { output_to_git_command_output(output) } +pub fn abort_rebase() -> io::Result { + let output = Command::new("git") + .args(["rebase", "--abort"]) + .output()?; + + output_to_git_command_output(output) +} + pub fn init_repository() -> io::Result { Command::new("git").args(["init", "--quiet"]).status() } diff --git a/src/core/sync.rs b/src/core/sync.rs index 56f73fd..43c9a8d 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -18,6 +18,7 @@ use crate::core::{adopt, commit, git, merge, orphan, reparent}; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SyncOptions { pub continue_operation: bool, + pub abort_operation: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -185,6 +186,10 @@ pub fn run_with_reporter(options: &SyncOptions, reporter: &mut F) -> io::Resu where F: FnMut(SyncEvent) -> io::Result<()>, { + if options.abort_operation { + return abort_sync(); + } + if !options.continue_operation { return run_full_sync_with_reporter(reporter); } @@ -307,6 +312,33 @@ where } } +fn abort_sync() -> io::Result { + let session = open_initialized("dagger is not initialized; run 'dgr init' first")?; + let pending_operation = load_operation(&session.paths)? + .ok_or_else(|| io::Error::other("no paused dgr operation to abort"))?; + + if git::is_rebase_in_progress(&session.repo) { + let abort_output = git::abort_rebase()?; + if !abort_output.status.success() { + return Ok(SyncOutcome { + status: abort_output.status, + completion: None, + failure_output: Some(abort_output.combined_output()), + paused: true, + }); + } + } + + clear_operation(&session.paths)?; + + Ok(SyncOutcome { + status: git::success_status()?, + completion: None, + failure_output: None, + paused: false, + }) +} + fn run_full_sync_with_reporter(reporter: &mut F) -> io::Result where F: FnMut(SyncEvent) -> io::Result<()>, diff --git a/src/core/workflow.rs b/src/core/workflow.rs index f6018a5..7c64e70 100644 --- a/src/core/workflow.rs +++ b/src/core/workflow.rs @@ -74,7 +74,7 @@ pub(crate) fn ensure_no_pending_operation( fn pending_operation_error(command_name: &str, paused_origin: &str) -> io::Error { io::Error::other(format!( - "dgr {command_name} cannot run while a dgr {paused_origin} operation is paused; run 'dgr sync --continue'" + "dgr {command_name} cannot run while a dgr {paused_origin} operation is paused; run 'dgr sync --continue' or 'dgr sync --abort'" )) } From 5870234a0560037edec0a11598e4b9915c295d43 Mon Sep 17 00:00:00 2001 From: DJ Date: Mon, 30 Mar 2026 19:30:41 -0700 Subject: [PATCH 3/5] fix: address Copilot review feedback on abort_sync - Prefix unused `pending_operation` variable with underscore - Add missing `abort_operation` field to SyncOptions struct literal in test Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/sync.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/sync.rs b/src/core/sync.rs index 43c9a8d..9a73e81 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -314,7 +314,7 @@ where fn abort_sync() -> io::Result { let session = open_initialized("dagger is not initialized; run 'dgr init' first")?; - let pending_operation = load_operation(&session.paths)? + let _pending_operation = load_operation(&session.paths)? .ok_or_else(|| io::Error::other("no paused dgr operation to abort"))?; if git::is_rebase_in_progress(&session.repo) { @@ -1655,6 +1655,7 @@ mod tests { let outcome = run_with_reporter( &SyncOptions { continue_operation: true, + abort_operation: false, }, &mut |event| { events.push(event.clone()); From 8691a50e95243bf74387dee27f7bdbaddc07c84e Mon Sep 17 00:00:00 2001 From: DJ Date: Sun, 5 Apr 2026 10:32:10 -0700 Subject: [PATCH 4/5] fix: address remaining Copilot review feedback on abort_sync - Remove unused variable binding in abort_sync; use is_none() check directly for clearer control flow - Confirm all SyncOptions struct literals include abort_operation field (all existing uses already covered via Default or explicit fields) - Add test coverage for abort_sync: verify abort clears pending operation after a paused sync, and returns an error when no pending operation exists Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/sync.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/src/core/sync.rs b/src/core/sync.rs index 9a73e81..aa8a4da 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -314,8 +314,10 @@ where fn abort_sync() -> io::Result { let session = open_initialized("dagger is not initialized; run 'dgr init' first")?; - let _pending_operation = load_operation(&session.paths)? - .ok_or_else(|| io::Error::other("no paused dgr operation to abort"))?; + + if load_operation(&session.paths)?.is_none() { + return Err(io::Error::other("no paused dgr operation to abort")); + } if git::is_rebase_in_progress(&session.repo) { let abort_output = git::abort_rebase()?; @@ -1378,6 +1380,7 @@ mod tests { pull_request_needs_repair, run, run_with_reporter, }; use crate::core::gh::{PullRequestState, PullRequestStatus}; + use crate::core::store::load_operation; use crate::core::test_support::{ append_file, commit_file, create_tracked_branch, git_ok, initialize_main_repo, with_temp_repo, @@ -1679,4 +1682,63 @@ mod tests { )); }); } + + #[test] + fn abort_cancels_paused_sync_and_clears_pending_operation() { + with_temp_repo("dgr-sync-abort", |repo| { + initialize_main_repo(repo); + crate::core::init::run(&crate::core::init::InitOptions::default()).unwrap(); + create_tracked_branch("feat/auth"); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + create_tracked_branch("feat/auth-ui"); + commit_file(repo, "shared.txt", "child\n", "feat: ui"); + git_ok(repo, &["checkout", "main"]); + commit_file(repo, "shared.txt", "main\n", "feat: trunk"); + git_ok(repo, &["checkout", "feat/auth"]); + + // Trigger a conflict so sync pauses + let paused = run(&SyncOptions::default()).unwrap(); + assert!(!paused.status.success()); + assert!(paused.paused); + + // Verify a pending operation exists + let session = + crate::core::store::open_initialized("should be initialized").unwrap(); + assert!(load_operation(&session.paths).unwrap().is_some()); + + // Abort the paused sync + let outcome = run(&SyncOptions { + continue_operation: false, + abort_operation: true, + }) + .unwrap(); + + assert!(outcome.status.success()); + assert!(!outcome.paused); + + // Verify the pending operation has been cleared + assert!(load_operation(&session.paths).unwrap().is_none()); + }); + } + + #[test] + fn abort_returns_error_when_no_pending_operation() { + with_temp_repo("dgr-sync-abort-no-op", |repo| { + initialize_main_repo(repo); + crate::core::init::run(&crate::core::init::InitOptions::default()).unwrap(); + + let result = run(&SyncOptions { + continue_operation: false, + abort_operation: true, + }); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no paused dgr operation to abort") + ); + }); + } } From 6961c260ea98a8bcd554912f02f42fb18a6bdf81 Mon Sep 17 00:00:00 2001 From: DJ Date: Tue, 14 Apr 2026 04:16:45 -0700 Subject: [PATCH 5/5] fix: return paused=false when abort_rebase fails in abort_sync When git rebase --abort fails, returning paused=true caused the CLI to show misleading --continue guidance. Now returns paused=false so the generic error path displays the actual failure output instead. Co-Authored-By: Claude Sonnet 4.6 --- src/core/sync.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/sync.rs b/src/core/sync.rs index aa8a4da..220ec4f 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -326,7 +326,7 @@ fn abort_sync() -> io::Result { status: abort_output.status, completion: None, failure_output: Some(abort_output.combined_output()), - paused: true, + paused: false, }); } }