Inspiration

I use doccano, an open-source text annotation tool with over 10 million Docker pulls. When I looked at it closely, it was stuck. Django 4.2 had been out of security support since April 2026, Python 3.10 reached end of life on October 1, and the lockfile carried 80 known advisories. Upstream had 25 bot upgrade PRs sitting open, the oldest from August 2024, and none had been merged since then.

The bots were doing their part. They bump a version. What they can't do is read the release notes, fix what breaks, and decide what happens when behavior changes. The bot's own Django PR only goes to 4.2.30, the last 4.2 release, which still leaves 8 Django advisories unfixed. Getting to 5.2 takes a person's afternoon, and maintainers rarely have one.

What it does

Unstuck is two custom GitLab Duo flows that run an upgrade campaign, with me approving every step.

  • Campaign Planner. I assign it a kick-off issue. It reads GitLab's Vulnerability Report, runs the lockfile audit itself, checks which Django and Python versions are still supported, and writes one ordered plan issue.
  • Upgrader. I assign it the plan, which is my approval. It takes the next step and opens one merge request: it moves only the packages that step names, fixes what breaks, and quotes the release note line behind every code change, so I can check each one in seconds. The MR shows advisories and tests before and after.

When a step changes behavior, the Upgrader stops and asks. In the live run, upgrading the login stack would have changed how Okta users are identified, so existing users could end up with a second account. It laid out the options and did nothing until I decided. I wrote my decision as a review comment and requested it as reviewer, and it came back with a data migration that keeps Okta users linked, plus tests for it.

Every go-ahead is a normal GitLab action: assign, comment, request a reviewer, merge. The agents never merge. Each merge builds an image and deploys to Google Cloud Run.

The loop also closes on its own. A daily Monitor pipeline re-runs the audit, and when a new advisory shows up it opens the next campaign's kick-off issue by itself. The day the first campaign finished, a new advisory appeared in certifi. The Monitor caught it and opened the next kick-off issue, and I assigned it to the Planner.

Results on doccano (first full run, all public in the project):

Before After
Lockfile advisories 80 across 14 packages 0 (104 packages audited)
Django 4.2, out of support 5.2 LTS, supported to April 2028
Python 3.10, end of life 3.12
Tests 541 passing 548 passing
Upgrade PRs 25 stuck bot PRs 6 MRs, reviewed and merged

That took about an hour of agent time across six merge requests. Each MR's evidence table shows how many advisories it closed: 21 for Django, 4 for the login stack, 23 for pyjwt and cryptography, and the last 32 in a patch batch.

How I built it

  • Plan: the Campaign Planner flow, triggered by an issue assignment.
  • Create: the Upgrader flow, one step per MR, following the rules and MR template in AGENTS.md.
  • Verify: JUnit tests, four linters and a dependency audit with a metrics report on every MR.
  • Secure: GitLab SAST, Secret Detection and Dependency Scanning, plus the Vulnerability Report the Planner reads.
  • Package: the image is built to Google Artifact Registry with a registry layer cache.
  • Release: a tag publishes a GitLab release with notes generated from the merged steps.
  • Configure: the deploy to Cloud Run has no stored keys. GitLab's OIDC token is exchanged through Workload Identity Federation.
  • Monitor: a smoke check reads the app's health JSON after every deploy, and a daily schedule re-runs the health check and the audit. New advisories open the next kick-off issue.
  • Govern: AGENTS.md sets the rules. One step at a time, only the named packages move, every change cites its source, and after three failed tries the step goes back to a person. Every refusal and hand-off is a comment with a label, so there is a record. When I assigned the kick-off issue again while a plan was open, the Planner refused and told me what to do instead.

The model does the reading: release notes across several Django versions, matched against this codebase. The tests, the audit and a person decide. The audit also refuses to report a number if it checked fewer packages than the lockfile pins, so a broken audit can't produce a false zero.

What I changed in doccano

doccano itself is upstream work (https://github.com/doccano/doccano, MIT), imported at commit ab6b765. Everything else was added for this hackathon: both flow definitions, AGENTS.md, the audit tooling, the full CI/CD pipeline, the Google Cloud setup, the Monitor step, and every campaign MR.

Challenges I ran into

  • GitLab records flow MRs under the person who gave the go-ahead, so the "require one approval" rule can't be met by one person. The review rule lives in AGENTS.md instead.
  • An early audit ran on the wrong Python interpreter and reported a false zero. That's why the audit now checks its own package count.
  • CI hit four infrastructure failures during the run, each fixed at the cause: a stalled Debian mirror, a hung Poetry installer, a 127-byte limit on the Google Cloud token exchange subject, and an agent session that failed to start.

Accomplishments I'm proud of

The moment the Upgrader stopped on the Okta change instead of guessing. And seeing the Monitor open the next campaign by itself the same day the first one finished.

What I learned

Agents are good at the reading and the busywork of an upgrade. The part worth keeping human is small and clear: a few decisions a maintainer actually cares about, asked at the right moment.

What's next

Point it at other stuck projects, and let the Monitor's daily kick-off issues keep them current.

Built With

  • django
  • docker
  • gitlab
  • gitlab-ci
  • gitlab-duo
  • google-artifact-registry
  • google-cloud-run
  • pip-audit
  • poetry
  • python
Share this project:

Updates

Submission history