Skip to content
Draft
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
99 changes: 84 additions & 15 deletions .github/workflows/update_lockfiles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
workflow_dispatch:
schedule:
- cron: "0 4 * * 1"
# TEMPORARY: run on PRs to verify the smart bump works in CI. On PRs it runs in
# --dry-run mode (no branch/commit/PR is created) — see the steps below. Remove this
# `pull_request:` trigger once verified.
pull_request:

permissions:
contents: write
Expand All @@ -13,7 +17,10 @@ jobs:
update-lockfiles:
name: Refresh mix.lock files
runs-on: ubuntu-latest
timeout-minutes: 15
# The smart bump runs the full test suite (including integration tests) to verify
# each update, and may bisect dependency-by-dependency on failure, so it needs a
# much larger budget than a blunt `mix deps.update --all`.
timeout-minutes: 120
steps:
- name: Get auth token
id: token
Expand All @@ -33,21 +40,43 @@ jobs:
elixir-version: "1.18"
otp-version: "27.2"

- name: Refresh lockfiles
- name: Install dependencies
run: mix deps.get

- name: Bump lockfiles
id: bump
# # TEMPORARY: on PRs, run in --dry-run mode so the lockfiles are restored and no
# # changes are committed — we only want to confirm the task runs green in CI.
# env:
# DRY_RUN: ${{ github.event_name == 'pull_request' && '--dry-run' || '' }}
run: |
for lock in mix.lock test_integrations/*/mix.lock; do
dir=$(dirname "$lock")
echo "==> Refreshing mix.lock in $dir"
(cd "$dir" && mix deps.update --all)
done
# Carefully bump patch/minor versions, verifying each against the full test
# suite. Only updates that keep everything green are written to the lockfiles;
# major bumps and anything that breaks tests are held back and reported.
mix sentry.bump_lockfiles --output-dir tmp/lockfile-bump --timeout 600 $DRY_RUN

# The run directory is timestamped; expose the newest one to later steps.
run_dir=$(ls -dt tmp/lockfile-bump/run-* | head -1)
echo "run_dir=$run_dir" >> "$GITHUB_OUTPUT"

- name: Upload bump report and logs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lockfile-bump-report
path: tmp/lockfile-bump/run-*
if-no-files-found: ignore

- name: Configure git
# TEMPORARY: skip all branch/commit/PR steps on PRs (verification is dry-run only).
if: github.event_name != 'pull_request'
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'

- name: Create branch
id: create-branch
if: github.event_name != 'pull_request'
run: |
# Stage first, then diff the index against HEAD: plain `git diff` ignores
# untracked files, so a brand-new project's lock would look unchanged.
Expand All @@ -71,25 +100,65 @@ jobs:
echo "commit_title=$COMMIT_TITLE" >> "$GITHUB_OUTPUT"

- name: Create pull request
if: steps.create-branch.outputs.changed == 'true'
if: github.event_name != 'pull_request' && steps.create-branch.outputs.changed == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BRANCH_NAME: ${{ steps.create-branch.outputs.branch_name }}
COMMIT_TITLE: ${{ steps.create-branch.outputs.commit_title }}
RUN_DIR: ${{ steps.bump.outputs.run_dir }}
with:
github-token: ${{ steps.token.outputs.token }}
script: |
const fs = require('fs');
const branchName = process.env.BRANCH_NAME;
const commitTitle = process.env.COMMIT_TITLE;
const prBody = `Automated weekly refresh of the committed \`mix.lock\` files via \`mix deps.update --all\`, keeping dependency pins current with the latest security patches allowed by each project's version constraints.

#skip-changelog

## Action required
- If CI passes on this PR, it's safe to approve and merge.
- If CI fails, a dependency update broke something — investigate before merging.
// Build a per-project summary from the bump report.
let summary = '';
try {
const report = JSON.parse(fs.readFileSync(`${process.env.RUN_DIR}/report.json`, 'utf8'));
const s = report.summary;
summary += `**Status:** \`${report.overall_status}\` — bumped ${s.bumped}, skipped (major) ${s.skipped_major}, failed ${s.failed}\n\n`;

for (const p of report.projects) {
if (!p.bumped.length && !p.skipped_major.length && !p.failed.length) continue;
summary += `<details><summary><code>${p.name}</code> — ${p.status}</summary>\n\n`;
if (p.bumped.length) {
summary += `**Bumped**\n`;
for (const b of p.bumped) summary += `- \`${b.dep}\` ${b.from} → ${b.to}\n`;
summary += `\n`;
}
if (p.skipped_major.length) {
summary += `**Held back (would cross a major boundary)**\n`;
for (const sk of p.skipped_major) summary += `- \`${sk.dep}\` ${sk.from} → ${sk.to} (${sk.reason})\n`;
summary += `\n`;
}
if (p.failed.length) {
summary += `**Failed verification**\n`;
for (const f of p.failed) summary += `- \`${f.dep}\` ${f.from} → ${f.to} — ${f.failure_type} (log: \`${f.log_file}\`)\n`;
summary += `\n`;
}
summary += `</details>\n\n`;
}
} catch (e) {
summary = `_Could not read bump report: ${e.message}_\n\n`;
}

_🤖 Automatically created by [.github/workflows/update_lockfiles.yml](https://github.com/getsentry/sentry-elixir/blob/master/.github/workflows/update_lockfiles.yml)._`.replace(/^ {12}/gm, '');
const prBody = [
"Automated weekly lockfile update produced by `mix sentry.bump_lockfiles`.",
"",
"Only patch/minor bumps that keep the full test suite (including integration tests) green are written to the lockfiles. Major bumps — and anything that broke the build or tests — are held back and listed below as candidates for manual follow-up.",
"",
"#skip-changelog",
"",
"## Summary",
summary,
"## Details",
"- Per-step logs and the full JSON report are attached as the **lockfile-bump-report** workflow artifact.",
"- CI on this PR re-validates the changes; if it passes it's safe to approve and merge.",
"",
"_🤖 Automatically created by [.github/workflows/update_lockfiles.yml](https://github.com/getsentry/sentry-elixir/blob/master/.github/workflows/update_lockfiles.yml)._",
].join("\n");

// Close superseded lockfile PRs — they're now obsolete.
const existingPRs = await github.paginate(github.rest.pulls.list, {
Expand Down
244 changes: 244 additions & 0 deletions lib/mix/tasks/sentry.bump_lockfiles.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
defmodule Mix.Tasks.Sentry.BumpLockfiles do
@shortdoc "Carefully bumps dependency lockfiles, keeping only changes that pass tests"

@moduledoc """
Carefully bumps the project's `mix.lock` files, keeping only the dependency updates
that are proven not to break compilation or tests.

The weekly `update_lockfiles` GitHub workflow refreshes every `mix.lock` with a blunt
`mix deps.update --all`, which can silently introduce a breaking dependency bump. This
task does the same refresh, but *gradually and safely*: it never crosses a major
version by default, validates against the full test suite (including integration
tests), and emits a JSON report naming exactly which dependencies were bumped, which
were skipped as major, and which broke the build or the tests.

## How it works

1. **Snapshot** every lockfile so any step can be reverted.
2. **Optimistic phase** — apply every policy-allowed bump at once and run the full
suite. If it's green, we're done.
3. **Gradual phase** (only on failure) — revert, then bump one dependency at a time,
running the full suite after each. A bump is kept only if everything stays green,
so the assembled lockfiles are always a validated, passing set, and the dependency
responsible for any breakage is identified.
4. **Artifacts** — write a self-contained run directory and print a colored summary.

## Run artifacts

Each run creates a timestamped directory under `--output-dir` (default
`tmp/lockfile-bump`) so results are easy to inspect and turn into follow-up tasks:

```text
tmp/lockfile-bump/
latest -> run-<timestamp> # symlink to the most recent run
run-<timestamp>/
report.json # full structured report
locks/ # the verified mix.lock files (used by --apply)
logs/
discover-<project>.log # output of `deps.update --all` per project
optimistic.log # the optimistic bump-everything validation
gradual-<dep>.log # full deps.get/compile/test output per attempted bump
final.log # the final full validation
```

Each failed dependency in `report.json` references its `log_file` so you can jump
straight to the relevant test output.

## Lockfiles

Manages the root `mix.lock` and `test_integrations/*/mix.lock` (the same set the
weekly workflow refreshes).

## Major versions

By default no bump crosses a major boundary. Following semver, a `0.x` minor bump
(e.g. `0.20 -> 0.21`) is also treated as breaking. Use `--allow-major` or
`--allow-major-for` to opt in.

## Performance

Best case (everything passes optimistically) runs the suite once. The gradual phase
runs the full suite per attempted dependency, which can be slow — scope it down with
`--only`, `--projects`, or `--skip-integrations` when iterating locally.

## Usage

```shell
mix sentry.bump_lockfiles
mix sentry.bump_lockfiles --skip-integrations --output-dir tmp/bumps
mix sentry.bump_lockfiles --allow-major-for opentelemetry,opentelemetry_api
mix sentry.bump_lockfiles --apply tmp/lockfile-bump/latest
```

## Options

* `--allow-major` - allow bumps that cross a major boundary
* `--allow-major-for dep1,dep2` - allow major bumps only for the listed dependencies
* `--strict-0x` / `--no-strict-0x` - treat `0.x` minor bumps as breaking (default: true)
* `--only dep1,dep2` - only consider the listed dependencies
* `--projects root,phoenix_app` - only operate on the listed projects
* `--skip-integrations` - only bump and validate the root project
* `--output-dir DIR` - base directory for run artifacts (default: `tmp/lockfile-bump`).
Each run creates a timestamped subdirectory; see "Run artifacts" above.
* `--apply PATH` - skip discovery/verification and apply the *exact* verified
lockfiles captured by a previous run (e.g. one produced by an earlier `--dry-run`).
`PATH` is a run directory (or its `report.json`); this copies the captured
lockfiles back verbatim and runs `mix deps.get`. No tests are re-run. Combine with
`--projects` to apply to a subset.
* `--dry-run` - run everything but restore the original lockfiles afterward
* `--timeout SECONDS` - hard cap per test run; the run is killed and recorded as a failure
* `--verbose` - stream subprocess output live
* `--no-final-check` - skip the final full validation of the assembled lockfiles
* `--keep-going` - always exit 0 (report-only); by default a `failed` result exits non-zero

"""

@moduledoc since: "13.3.0"

use Mix.Task

alias Sentry.Dev.Applier
alias Sentry.Dev.Bumper
alias Sentry.Dev.Report

@switches [
allow_major: :boolean,
allow_major_for: :string,
strict_0x: :boolean,
only: :string,
projects: :string,
skip_integrations: :boolean,
output_dir: :string,
apply: :string,
dry_run: :boolean,
timeout: :integer,
verbose: :boolean,
no_final_check: :boolean,
keep_going: :boolean
]

@default_output_dir "tmp/lockfile-bump"

@impl true
def run(args) do
{opts, _args} = OptionParser.parse!(args, strict: @switches)

case opts[:apply] do
nil -> bump(opts)
path -> apply_report(path, opts)
end
end

defp apply_report(path, opts) do
summary = path |> report_path() |> Report.read() |> Applier.apply(opts)
Applier.print_summary(summary)

if not summary.all_applied? and not opts[:keep_going] do
Mix.shell().error("Some lockfiles could not be applied. See above.")
exit({:shutdown, 1})
end
end

# `--apply` accepts either a run directory or a direct path to its report.json.
defp report_path(path) do
if File.dir?(path), do: Path.join(path, "report.json"), else: path
end

defp bump(opts) do
run_dir = new_run_dir(opts)
logs_dir = Path.join(run_dir, "logs")
locks_dir = Path.join(run_dir, "locks")
File.mkdir_p!(logs_dir)
backups = if opts[:dry_run], do: backup_lockfiles(), else: nil

report =
try do
result = opts |> Keyword.put(:logs_dir, logs_dir) |> Bumper.run()
# Capture the verified lockfiles (on disk now) so they can be applied later,
# before any dry-run restore reverts the working tree.
save_verified_locks(locks_dir)

result
|> Report.build()
|> Map.merge(%{
run_dir: Path.relative_to_cwd(run_dir),
logs_dir: Path.relative_to_cwd(logs_dir),
verified_locks_dir: Path.relative_to_cwd(locks_dir)
})
after
if backups, do: restore_lockfiles(backups)
end

Report.write(report, Path.join(run_dir, "report.json"))
Report.print_summary(report)
update_latest(run_dir)

Check failure on line 174 in lib/mix/tasks/sentry.bump_lockfiles.ex

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.20.0-otp-29, OTP 29.0.1)

Expression produces a value of type 'ok' | {'error',atom()}, but this value is unmatched

Mix.shell().info([
"\nRun artifacts written to ",
:cyan,
Path.relative_to_cwd(run_dir),
:reset
])

Mix.shell().info([
"Apply the verified lockfiles later with: ",
:bright,
"mix sentry.bump_lockfiles --apply #{Path.relative_to_cwd(run_dir)}",
:reset
])

if report.overall_status == "failed" and not opts[:keep_going] do
Mix.shell().error(
"Could not assemble a green set of lockfiles. See the report for details."
)

exit({:shutdown, 1})
end
end

defp new_run_dir(opts) do
base = Keyword.get(opts, :output_dir, @default_output_dir)
timestamp = DateTime.utc_now() |> DateTime.to_iso8601() |> String.replace(":", "-")
Path.join(base, "run-#{timestamp}")
end

# Best-effort `latest` symlink pointing at the most recent run, so `--apply <base>/latest`
# works without knowing the timestamp. Tolerates filesystems that disallow symlinks.
defp update_latest(run_dir) do
latest = Path.join(Path.dirname(run_dir), "latest")
File.rm(latest)

Check failure on line 209 in lib/mix/tasks/sentry.bump_lockfiles.ex

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.20.0-otp-29, OTP 29.0.1)

Expression produces a value of type 'ok' | {'error',atom()}, but this value is unmatched
File.ln_s(Path.basename(run_dir), latest)
rescue
_ -> :ok
end

defp lockfiles do
Path.wildcard("mix.lock") ++ Path.wildcard("test_integrations/*/mix.lock")
end

defp save_verified_locks(locks_dir) do
Enum.each(lockfiles(), fn lock ->
dest = Path.join(locks_dir, lock)
File.mkdir_p!(Path.dirname(dest))
File.cp!(lock, dest)
end)
end

defp backup_lockfiles do
Map.new(lockfiles(), fn lock ->
backup = Path.join(System.tmp_dir!(), "sentry_bump_dryrun_#{:erlang.phash2(lock)}.lock")
File.cp!(lock, backup)
{lock, backup}
end)
end

defp restore_lockfiles(backups) do
Enum.each(backups, fn {lock, backup} ->
File.cp!(backup, lock)
File.rm(backup)

Check failure on line 238 in lib/mix/tasks/sentry.bump_lockfiles.ex

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.20.0-otp-29, OTP 29.0.1)

Expression produces a value of type 'ok' | {'error',atom()}, but this value is unmatched
# Reconcile the on-disk deps with the restored lock, otherwise the next compile
# fails with a lock mismatch because the run fetched newer versions.
Sentry.Dev.Cmd.mix(Path.dirname(lock), ["deps.get"])
end)
end
end
Loading
Loading