Andrew Mercer
on this page

Managing Public GitLab Repositories: A Comprehensive Guide

1. Why Public Repos Need a Different Playbook

A private repo's threat model is "who on my team can see this." A public repo's threat model is "the entire internet can see this, fork it, run CI against it, and try to extract anything you didn't mean to expose." Every decision below — from branch protection to how you handle a banned-terms list — flows from that difference.


2. Repository Hygiene Baseline

2.1 Required files

  • README.md — what it is, install/usage, badges (pipeline status, license, latest release).
  • LICENSE — pick one deliberately (MIT/Apache-2.0 for permissive, GPL/AGPL if you want derivative works to stay open). No license = "all rights reserved" by default, which surprises contributors.
  • CONTRIBUTING.md — branch naming, commit conventions, how to run tests locally, DCO/CLA requirements if any.
  • CODE_OF_CONDUCT.md — especially once you have external contributors.
  • SECURITY.md — a private disclosure path (email or GitLab's confidential issue support) so vulnerabilities aren't dropped as public issues.
  • .gitignore / .gitattributes — tuned per language; keep this in your skel repo so every project inherits it.
  • CODEOWNERS — auto-requests review from the right person/team on matching paths; combine with required approvals so nothing merges unreviewed.

2.2 Repository settings

  • Visibility: confirm it's intentionally Public, not just "not Private" — GitLab also has an Internal tier that's easy to pick by mistake.
  • Default branch protection: protect main, require merge requests (no direct pushes, including from maintainers), require at least one approval, require pipelines to pass before merge.
  • Merge request settings: enable "Merge only if pipeline succeeds," "Merge only if all threads resolved," and squash-on-merge if you want a clean linear history.
  • Fork settings: decide whether you want to allow forking at all (rare to disable) and whether fork MRs can be merged directly or must be cherry-picked.
  • Issue/MR templates: .gitlab/issue_templates/ and .gitlab/merge_request_templates/ cut down on low-quality reports and remind external contributors what info you need.

3. The Public-Repo Threat Model in Practice

Three attack surfaces matter more here than in a private repo:

  1. Secrets in the repo itself — committed .env files, hardcoded tokens, cloud credentials in Terraform state or CI config.
  2. Secrets in CI/CD — variables that fork/external merge-request pipelines can read or exfiltrate via a crafted .gitlab-ci.yml.
  3. Secrets in build artifacts/logs — a value that never appears in source but gets printed to a log, baked into a binary, or embedded in a published container image.

GitLab's single biggest lever here: protected CI/CD variables only get exposed to pipelines running on protected branches/tags. A merge request from an external fork targeting main runs on a detached pipeline that does not inherit protected variables. This is the mechanism you'll lean on for the banned-terms scanner below — it's also why "just add a CI variable" isn't automatically safe for anything sensitive.

3.1 Secret scanning & SAST

GitLab (even the free tier, to varying depth) supports:

include:
  - template: Security/Secret-Detection.gitlab-ci.yml
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Dependency-Scanning.gitlab-ci.yml

Run these on every MR. Secret Detection catches committed credentials before merge; enable push rules (Settings → Repository → Push Rules) to reject commits containing detected secrets at push time, not just at CI time — much cheaper to fix before it's ever in history.

If a secret does get committed, rotate it immediatelygit filter-repo / BFG history rewrites don't help once something is public, because forks and caches (including search engine and GitLab's own object storage) may retain it.

3.2 Dependency and container scanning

  • Dependency Scanning / renovate or GitLab's Dependabot-equivalent for outdated/vulnerable packages.
  • Container Scanning template if you publish images.
  • Pin dependency versions and review lockfile diffs on MRs — a public repo is a more attractive dependency-confusion / typosquat target.

3.3 Runner considerations

  • For public repos, use GitLab's shared runners for external contributor MRs, not your own self-hosted runners, unless you fully understand the blast radius — an untrusted .gitlab-ci.yml from a fork MR runs arbitrary code. Self-hosted runners processing untrusted forks is a classic way to leak internal network access or your runner's own secrets.
  • If you must use your own runners, run them in Docker/Kubernetes executor mode (not shell), with no privileged mode, and treat fork-MR pipelines as hostile until proven otherwise.

4. Governance: Keeping a Healthy Public Project Alive

  • Issue triage cadence — label taxonomy (bug, enhancement, good first issue, needs-info), and a stale-bot or scheduled pipeline to nudge inactive issues.
  • Release process — semantic versioning, CHANGELOG.md (or auto-generated via conventional commits + a tool like git-cliff), signed tags if you want provenance guarantees.
  • GitLab Releases tied to tags, with built artifacts attached — gives users a stable download point instead of "clone and build."
  • GitLab Pages for docs — natural fit given your andrewmercer.net setup; you could even mirror per-project docs there or host project-specific docs via Pages directly from the repo.
  • Community health score — GitLab surfaces this; worth checking periodically (Analyze → Repository analytics won't show it directly, but the presence/absence of the files above is what search engines and contributors judge you on).

5. Banned-Terms Scanning in CI — Without Exposing the List

This is the interesting design problem: you have a Rust scanner (your banned-terms-scanner tool, currently used against django-docs) that flags legacy company/product names. On a public repo, the banned-terms list itself is often the sensitive artifact — it can reveal internal codenames, unreleased product names, or acquisition/rebrand details you don't want indexed by search engines or scraped from your CI config.

The core problem: anything a public pipeline can read, a sufficiently motivated fork-MR can usually exfiltrate, because the attacker controls .gitlab-ci.yml in their fork and can add a step that just prints or curls out any variable/file it has access to. So "hide the list in a CI variable and scan locally" is only as strong as GitLab's protected-variable isolation — good, but not bulletproof if you ever run the scan on a non-protected context.

Here are the approaches, from weakest to strongest, so you can pick based on how sensitive the list actually is:

5.1 Weak: plaintext list in the repo

Don't. If the list itself is the reason you're scanning for it, storing it in the same public repo defeats the purpose — it's indexed by every search engine and GitLab's own code search the moment it's pushed.

5.2 Better: CI/CD variable + protected branches/pipelines

Store the list as a masked, protected CI/CD variable (or as a Secure File via the Secure Files API if it's too large/structured for a variable). Mark it protected so it's only injected into pipelines running on protected branches or protected tags.

banned-terms-scan:
  stage: test
  image: registry.gitlab.com/yourgroup/banned-terms-scanner:latest
  rules:
    - if: '$CI_COMMIT_REF_PROTECTED == "true"'
  script:
    - banned-terms-scanner --terms-file "$BANNED_TERMS_LIST" --path .
  • This means external fork MRs won't get the list at all, so the job should be conditioned to only run (or only run meaningfully) on protected refs — e.g., run it on main/release branches, and treat MR pipelines from forks as "scan deferred until merge" or run a no-op placeholder job so the pipeline status still shows expected jobs.
  • Risk: masking only stops the value being printed to job logs via GitLab's log filter — it does nothing if your own script deliberately echoes it, and it doesn't stop someone with maintainer access to add a malicious step on a protected branch. Trust boundary = "who can push/merge to protected branches," which is the right boundary for most orgs.
  • Weakness: this still puts the plaintext list, briefly, into the runner's environment/filesystem during the job. For most banned-terms use cases (internal codenames, not, say, cryptographic material) this is an acceptable residual risk.

5.3 Stronger: ship a compiled binary, keep the term list source-private

Keep the actual wordlist in a separate private repo, compile it into your Rust scanner as an embedded, obfuscated data structure (not a plaintext file bundled alongside the binary), and publish only the compiled binary (or a container image) to a registry that the public repo's CI pulls from.

banned-terms-scan:
  stage: test
  image: registry.gitlab.com/yourgroup/banned-terms-scanner:latest  # built from a PRIVATE source repo
  script:
    - banned-terms-scanner --path .
  • The public repo's .gitlab-ci.yml never references the list at all — just an opaque image tag.
  • The binary itself technically contains the terms (e.g., embedded via include_str!/include_bytes! at compile time), so this resists casual inspection of the public repo and its CI config, but not resists someone downloading the published image and running strings on it. If that's a real concern, see 5.4.
  • This is a good fit for your setup: banned-terms-scanner already exists; the change is (a) move the wordlist out of django-docs into a private companion repo, (b) build/publish the binary via that private repo's own pipeline, (c) have the public repo consume only the artifact.

5.4 Strongest: hash-based matching (list never exists in plaintext at scan time)

Store salted hashes of each banned term instead of the terms themselves. The scanner tokenizes the target repo's text, hashes each candidate token/n-gram with the same salt, and compares against the hash set.

# banned-terms.hashes (safe to ship even in a public artifact)
sha256(salt + "legacyproductname") = 9f2a...  
sha256(salt + "internalcodename")  = 3c7b...
  • Pros: even if this file leaks, recovering the original terms requires a dictionary/brute-force attack per token — expensive if terms are multi-word phrases and the salt is unique per project.
  • Cons: (a) this only works for exact-token matching — no fuzzy/substring/partial matches, which limits catching variants or partial mentions; (b) short or guessable single words are still crackable via rainbow tables unless the salt is kept private too (in which case you're back to protecting a secret, just a smaller one); (c) you lose the ability to give a human-readable "here's what matched" message without a private lookup table to reverse the hash back to the term (you can still say "banned term found at line N," just not which term, in public output).
  • This is the right tool when the concept of scanning is fine to be public but the exact strings must never appear anywhere reachable from the public repo, even in binary form.

5.5 Most robust: multi-project / downstream pipeline (list never touches the public project's execution context)

Use a separate, private GitLab project that owns both the list and the scanning logic. Trigger it as a downstream pipeline from the public repo, passing only the repo checkout (or a diff/artifact of changed files), and get back a pass/fail status, nothing else.

# public repo's .gitlab-ci.yml
trigger-banned-terms-check:
  stage: test
  trigger:
    project: yourgroup/private-banned-terms-checker
    branch: main
    strategy: depend   # public pipeline waits on and reflects the downstream result
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

The private project's pipeline:

# private-banned-terms-checker's .gitlab-ci.yml
scan:
  script:
    - git clone --depth 1 --branch "$UPSTREAM_REF" "$UPSTREAM_REPO_URL" target-repo
    - banned-terms-scanner --terms-file "$BANNED_TERMS_LIST" --path target-repo
  • The banned terms list, the scanning binary, and any intermediate matches all live and execute entirely inside a private project. The public project's runner/logs/artifacts never see the list — only a success/failed status propagates back via strategy: depend.
  • This is the approach I'd actually recommend if the list is genuinely sensitive (e.g., pre-announcement product names, legal-sensitive terms): it moves the trust boundary from "this CI job's runtime" to "who can access the private project," which is the same boundary you already manage for every other private repo.
  • Trade-off: slightly more infrastructure (a second project, a trigger token or CI/CD job token with cross-project permissions), and you lose inline "here's the exact line" annotations in the public MR unless you deliberately pipe back a redacted summary (e.g., file + line number, no term text) from the private project.

5.6 Recommendation for your setup

Given you already run banned-terms-scanner against django-docs: - If django-docs is (or becomes) public and the terms are mildly sensitive (old internal names, not legally hazardous), 5.3 (private source repo, public consumes compiled binary/image) is a good cost/benefit — small change from what you have now, meaningfully reduces exposure, keeps everything inline in one pipeline. - If the terms are sensitive enough that even a determined strings/binary-diffing attempt matters, layer 5.4 (hashing) on top of 5.3 — publish only the binary and only hashes, not embedded plaintext. - If it ever needs to be airtight — e.g., legal or PR-sensitivity — go straight to 5.5 (downstream private-project pipeline); it's the only option where the plaintext list is never present in the public project's execution context at all, not even transiently.

Either way, also apply 5.2's protected-branch gating regardless of which storage strategy you pick — it costs nothing and closes the fork-MR loophole for any variables that do end up in the public project.


6. Quick Reference Checklist

  • [ ] LICENSE, README, CONTRIBUTING, SECURITY.md, CODEOWNERS in place
  • [ ] main protected, MRs required, approvals required, pipeline-must-pass enabled
  • [ ] Push rules block committed secrets
  • [ ] Secret Detection + SAST + Dependency Scanning templates included in CI
  • [ ] Shared runners (not self-hosted) for untrusted fork MRs, or hardened non-privileged runners if self-hosted
  • [ ] CI/CD variables that matter are marked protected + masked
  • [ ] Banned-terms list stored per the sensitivity tier above (5.2–5.5), not committed in plaintext
  • [ ] Release process + CHANGELOG automated from conventional commits
  • [ ] Issue/MR templates and label taxonomy defined