Automatically watermarking files with GitHub Actions

October 15, 2026 · 6 min read · github-actions, git, bece-vault

BECE Vault is a public notes library. Every file sits in a public GitHub repository, and anyone can download any of them. That's the whole point of it. It also means anyone can grab a PDF and upload it somewhere else with no sign of where it came from.

I didn't want to lock anything away. I just wanted each file to carry a small line saying where it's from. Doing that by hand for hundreds of files, and then for every new one after that, was never going to happen. So I set up GitHub Actions to do it for me. This post covers how that works and the problems I ran into on the way.

Two repositories

The idea is simple. There are two repositories.

The private one holds the clean originals, and that's where I add notes. The public one holds the same content, except every PDF and image in it has been watermarked.

A workflow in the private repository runs every time I push. It takes the new commits and replays them, one by one, onto the public repository, stamping each new PDF and image along the way. Every replayed commit keeps its original author, date and message. Only the committer changes, to a bot account. So the public history looks just like mine, only with stamped files.

Stamping a PDF

The watermark is meant to be easy to miss. It's a thin strip under each page with a clickable "Available on notes.arpanadhikari7.com.np" line, plus a very faint logo in the middle of the page. The script uses pdf-lib for PDFs and sharp for images.

Here's a simplified version of the PDF part:

import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';

const STRIP = 18; // points of extra space added below each page

export async function stampPdf(bytes) {
  const pdf = await PDFDocument.load(bytes);
  if ((pdf.getKeywords() || '').includes('watermarked')) return bytes; // never stamp twice

  const font = await pdf.embedFont(StandardFonts.Helvetica);
  for (const page of pdf.getPages()) {
    const { x, y, width, height } = page.getMediaBox();
    // Grow the page downwards so the line sits below the content, not on top of it.
    page.setMediaBox(x, y - STRIP, width, height + STRIP);
    page.setCropBox(x, y - STRIP, width, height + STRIP);
    page.drawText('Available on notes.example.com', {
      x: x + 12, y: y - STRIP + 5, size: 8, font, color: rgb(0.4, 0.4, 0.4),
    });
  }
  pdf.setKeywords(['watermarked']);
  return pdf.save();
}

Two small choices in there turned out to matter a lot.

First, a file is never stamped twice. Each stamped file gets a marker, and the script skips anything that already has one. That means I can run any step of the pipeline as many times as I like and it only ever touches new files. When a workflow fails halfway, I just run it again.

Second, the page grows instead of the line being drawn over it. Adding a strip of space at the bottom means the watermark never covers anything in the notes.

Pull requests from contributors

Anyone can contribute notes by opening a pull request on the public repository. That raised a question: the public repo is only supposed to contain stamped files, but a contributor's PDF shows up clean.

So a second workflow runs on every pull request. It stamps the new files inside the pull request itself, and saves the clean copies in the private repository. By the time I merge, the files going into the main branch are already watermarked. A third workflow then copies merged contributions back into the private repo, using those saved clean copies, so the two repositories stay in sync.

The security catch

To stamp a contributor's files, the workflow has to push to their pull request, and pushing needs a token. GitHub's pull_request_target event gives a workflow access to secrets even for pull requests from forks. That's exactly what I needed, and also exactly what makes it dangerous. If the workflow ever ran code from the pull request, someone could submit a malicious PR and walk off with the token.

So the rule is strict: the workflow only runs scripts from the main branch, and it treats the pull request's files as plain data.

on:
  pull_request_target:
    types: [opened, synchronize, reopened]

jobs:
  watermark:
    runs-on: ubuntu-latest
    steps:
      - name: Scripts from main (trusted)
        uses: actions/checkout@v4
        with:
          ref: main
          path: base
      - name: The pull request's files (data only)
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name }}
          ref: ${{ github.event.pull_request.head.ref }}
          path: pr
      # Only scripts from ./base are ever executed.

If you're thinking of using pull_request_target yourself, read GitHub's security notes on it first. It's very easy to get wrong.

Repositories too big to clone

The library is several gigabytes. Cloning all of it on every workflow run would be slow and wasteful. Instead, the workflows use partial, sparse checkouts. Only the scripts end up on disk, and file contents are fetched only when a commit actually needs them.

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
    filter: blob:none        # don't download file contents up front
    sparse-checkout: |
      .github
      Website

The replay writes each commit's changed files, commits them, and clears them from disk again. A single run never needs the whole library at once.

Stamping the old files

All of that handles new files. But hundreds of files had already been published without a watermark. The easy fix would have been one big "watermark everything" commit, and that would have buried the history of who added which file and when.

So I rewrote the public history instead, replaying every old commit with its files stamped and keeping every commit, author and date as they were. Rewriting public history is usually something you avoid, and I didn't do it casually. It was a one-time change, made while I was still the only person committing to the repository, so nobody else's work had to be rebased.

What I took away from it

The biggest one: make every step safe to repeat. Because the stamper skips files it has already done, a failed run never leaves a mess. I fix the problem and run it again.

I also became a lot more careful with secrets in CI. pull_request_target is powerful, and the only safe way to use it is to never run code you didn't write.

And I found out how much Git can do on its own. Replaying commits, keeping their authors, fetching file contents only when needed. All of that is built in. You just don't need it until a project gets big.

The real scripts are in the BECE_Notes repository under .github/ if you want to read them.

Keep reading

All posts