GitHub avatar

Fox's Blog

Repo to VPS: Turn GitHub Actions into a free VPS with persistent storage

How to turn a GitHub Actions runner into an always-on VPS using git as persistent storage -- tmate, inotify, and commit --amend.

GitHub gives you a free VPS for 6h. I found how to make it permanent.

GitHub Actions gives you free Linux machines.

Like, real Ubuntu servers. 2 cores, 7 GB RAM, 14 GB disk. Free. For 6h per run.

The only "problem": at the end of the run, everything gets wiped. The machine is disposable. You install stuff, you code, you configure... and poof, at the end everything disappears. Like you did nothing.

Except if.

Except if you use git as a hard drive.

And just like that, you've got a free VPS with persistent storage that survives runs. You reconnect, everything's still there. You pick up right where you left off.

It's completely busted. Let me explain xD


The context: GitHub Actions runners

When you launch a GitHub Actions workflow, GitHub hands you a VM.

It's meant to build your code, run your tests, deploy. The workflow runs, does its thing, and the machine gets destroyed.

But nothing's stopping you from doing something else with that VM. Like, opening an SSH shell on it and using it as a server.

The thing is, these machines are stateless and temporary:

  • Temporary: 6h max per run (timeout-minutes: 360, GitHub's ceiling)
  • Stateless: everything gets wiped at the end

So to turn it into a usable VPS, you gotta solve two problems:

  1. How do you connect to it in real time?
  2. How do you keep the disk between runs?

That's where it becomes a dirty hack.


Problem 1: live SSH with tmate

tmate is a fork of tmux that creates a shareable SSH session.

You launch it on a machine, it generates two links:

You connect with one of those links, and boom, you're in a shell on the machine. In real time.

So the workflow launches tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# grab the connection links
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

And these links get written straight to the repo's README by a Python script. You open your repo, you see the connection link, you click. There you are in your VPS.

First problem solved. But the second one is really wild.


Problem 2: git as a hard drive

Here's the crazy thing.

The machine gets wiped every run. So we store the filesystem in a dedicated git branch called filesystem.

On startup, the script restores the state from that branch:

filesystem_branch="filesystem"

# fetch the filesystem branch from remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restore the workspace from that branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

The filesystem branch IS your hard drive. Your files, your installs, your configs -- it's all in there.

You see the trick? The machine is disposable, but the disk lives in git. You restart the workflow, the disk is restored, you pick up exactly where you left off.

It's like a VPS that hibernates. Except the hibernation is a git repo xD

First launch: creating the empty disk

On the very first run, the filesystem branch doesn't exist yet. Gotta create it. And it's not trivial:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan is the key. An orphan branch is a branch with no history whatsoever -- like starting from an empty repo.

Why orphan? Because you DON'T want your persistent disk dragging along all your source code history. The disk is its own thing, with its own life. It starts blank.

And the git ls-remote --exit-code at the start is just a clean check: "does this branch already exist on the remote?" If yes, don't touch anything. If no, create it. Idempotent, as we like it.

Selective git clean: protecting caches

This line deserves a pause:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx nukes EVERYTHING not tracked by git. Normally that's violent -- it deep-cleans the workspace.

But the -e (exclude) flags protect certain things:

  • .apt-cache → APT package cache (we'll get back to this, it's clever)
  • .cache → generic cache
  • host.conf → session SSH address
  • tmate.sock → current tmate session socket

If you cleaned those files, you'd break the active session or lose your cache. So they get spared during the reset.

A stupid detail, but without it everything breaks.


Autosave: inotify watching everything

Alright, but how do files end up in the filesystem branch?

Answer: a watcher that monitors ALL file changes and commits/pushes automatically.

The magic tool is inotifywait (from the inotify-tools package). It watches the filesystem at the kernel level and triggers whenever a file changes.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce if lots of changes at once
  done
}

autosave &

Let's break down the inotify flags, because each one matters:

  • -r → recursive, watches all subdirectories
  • -e modify,create,delete,move → reacts to these 4 event types
  • --exclude '...' → a regex to ignore certain files

The --exclude is crucial. Look what it ignores:

  • .git → obviously, otherwise each commit would trigger an autosave which would trigger a commit... infinite loop. Disaster.
  • .apt-cache and .cache → caches that change all the time and we don't want to spam in git
  • host.conf and tmate.sock → session files that change constantly
  • .gitignore, .txt.swp → temporary files (.swp are vim's edit files)

Without this exclude, you'd get an autosave looping on its own changes. The .git in the list is THE line that keeps you from shooting yourself in the foot.

You modify a file? inotify detects it instantly, it commits, it pushes. In under a second, your change is in the filesystem branch.

You install something, write code, touch a config -- everything is saved in real time, automatically, without you doing a thing.

You literally have an auto-save system for your entire disk. Broken.

The debounce: don't spam git

The sleep 1 after each save is a debounce.

When you save a file in an editor, it often generates multiple filesystem events in a burst (create a temp file, rename, delete the old one...). Without debounce, you'd trigger 3-4 commits for a single save.

The sleep 1 says: "wait one second after a save, let the burst settle, then listen again." It groups nearby changes into a single commit. Clever.

And a periodic save on top

In case inotify misses something, there's also a save every 5 seconds:

periodic_save() {
  while true; do
    sync_from_remote   # fetch any remote changes
    sleep 5
    commit_and_push
  done
}

periodic_save &

Belt AND suspenders. We really don't want to lose the disk state.


The clever detail: a single commit

If you commit on every file change, you'll pile up thousands of commits. After an hour of session, your git history explodes. The repo gets huge. It's disgusting.

The solution is elegant: we amend the existing commit instead of creating a new one.

commit_and_push() {
  (
    flock -n 200 || return   # lock so two saves don't run at the same time

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # don't touch the scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: overwrites the previous commit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend means: "replace the last commit with this one."

So the filesystem branch ALWAYS has a single commit. No matter how many times you save. It's just a snapshot of the current state, force-pushed over and over.

The flock is a lock: since there are two save loops (inotify + periodic), you gotta prevent them from running git at the same time and stepping on each other. One git process at a time.

Clean.


sync_from_remote: handling multiple sessions

Here's something you don't think about at first: what if you launch TWO runs at the same time? Or if one session modifies the filesystem branch while another is running?

The script handles this with a sync_from_remote before each commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

The --ff-only (fast-forward only) is important: it means "merge ONLY if we can advance cleanly, without creating a merge commit."

If the two branches have diverged (like, two sessions modified different things), the fast-forward silently fails (2>/dev/null || true) and we keep the local state. It's not a perfect merge system, but it avoids corruption in the simple case where only one session is running.

Honestly, you shouldn't launch 3 parallel sessions on the same repo. But the code still tries not to explode if it happens. That's defense.


The APT cache: install fast

There's a detail in the workflow that doesn't look like much but is well thought out:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate and inotify-tools are installed via an action that caches APT packages.

On the first run, it downloads and installs. On subsequent runs, it's restored from the GitHub Actions cache -- faster, no need to re-download.

And remember the git clean -fdx -e .apt-cache from earlier? It's related. The .apt-cache folder is protected from cleanup precisely so the packages you install during your session can persist a bit.

It all holds together. I thought through the entire lifecycle.


Scripts stashed in /tmp

Another vicious but clever detail. Right at the start of the script:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

The scripts (update_readme.py, etc.) are copied to /tmp BEFORE touching the filesystem branch.

Why? Because when you do the git reset --hard to the filesystem branch (which is empty at first, or contains your disk), the .github/scripts files from the source repo disappear from the workspace.

But the script still needs them during the session (to update the README each time tmate restarts). So it stashes them in /tmp, out of git's reach:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

If you don't think of it, you'll spend 30 minutes wondering why your script disappeared. I thought of it.


The custom shell

A little final comfort: the session gives you a configured shell, not a bare bash.

The prestart.sh copies a custom .bashrc:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc   # same for root via sudo

And this .bashrc has a colored prompt, aliases (ll, lla, rm -i), and most importantly a clever exit override:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D does the same as exit
bind -x '"\C-d": "exit"'

When you type exit (or Ctrl+D), it cleanly kills the tmate processes before closing. This avoids leaving zombie tmate sessions hanging around on the machine.

There's also a tmate-detach function if you want to disconnect WITHOUT killing the session (to reconnect later). Comfort detail, but it shows the level of care.


tmate that restarts itself

Little comfort: if you type exit in your shell, normally the tmate session dies and you're disconnected for good.

Except here, tmate is in a while true loop:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

You exit? The session restarts by itself. You can reconnect with the same link.

That's stupid, but it makes it usable.


Reconnecting in one command

How do you reconnect after a disconnect, without digging through run logs every time?

The tmate SSH address gets written to a host.conf file, itself committed to the filesystem branch:

printf '%s' "${tmate_ssh#ssh }" > host.conf

And since this file is in git, you can grab it via the GitHub API with a single command:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

You run that, it fetches the current SSH address from the repo, and connects you directly. Even if the address changed between sessions.

So smooth.


The full flow

Here's the recap:

1. You trigger the workflow (push or manual button)
2. GitHub gives you an Ubuntu VM
3. The script restores the disk from the "filesystem" branch
4. inotify starts watching all changes
5. periodic_save commits every 5s as backup
6. tmate starts → generates SSH/web links
7. The links are written to the README + host.conf
8. You connect via ssh or the web terminal
9. You do whatever you want (code, install, debug...)
   └── every file change = instant autosave to git
10. 6h later, GitHub kills the VM
11. But your disk is intact in the "filesystem" branch
12. You restart the workflow → back to step 3, everything's still there

A VPS. Free. With persistent storage. Just using git and GitHub Actions.


Alright, gotta be honest: the limits

It's a hack, not a real VPS. So:

  • 6h max per run. Gotta restart the workflow regularly. No infinite uptime.
  • Not for production. You're not gonna host your site on this. It's for exploring, dev, debug, testing stuff in a disposable-but-recoverable Linux.
  • GitHub sees everything. It's their machines. Don't put anything sensitive.
  • Keep the repo private. You're exposing an SSH shell. A public repo = anyone can potentially connect. Bad idea.
  • It's pushing the ToS. GitHub Actions is meant for CI/CD, not free VPS. So use it sparingly, for legit stuff, without abusing it.

The real Achilles heel: git hates big files

There's a more technical limit, and it's the most important one to understand.

Git is made for text, not for a filesystem.

The persistent disk lives in a git branch. So everything you save goes through git. And git:

  • handles large binary files poorly (a 2 GB Docker image in git? forget it)
  • has a 100 MB per file hard limit on GitHub (you can't push past it)
  • recommends staying under ~5 GB per repo

So if you npm install a project with 500 MB of node_modules, or you build something that spits out heavy binaries, the push to filesystem will either crawl or fail outright.

The git commit --amend helps (one commit, no bloating history), but it doesn't change the fact that a 200 MB file will never make it.

Basically: it works great for code, configs, small files. It does NOT work for storing large data or binary artifacts. Gotta keep that in mind for what you do in your session.

It's not a full system snapshot

Another important nuance: the filesystem branch saves the workspace (the repo folder), not the entire system.

If you run apt install htop, the binary goes to /usr/bin/htop, which is OUTSIDE the workspace. So it won't be saved. On the next run, you'll need to reinstall it.

That's why there's the APT cache and prestart.sh: to re-prep the system environment on each startup, since only the workspace persists.

If you want your installs to survive, you gotta put them in the workspace (like, install to a local folder instead of system-wide). It's a mindset shift.


Free VPS vs real VPS: the match

repo-to-vps Real VPS (5€/month)
Price 0€ ~5-10€/month
Uptime 6h, gotta restart 24/7
Disk git branch, small files real SSD, several GB
RAM ~7 GB (generous!) 1-2 GB typically
CPU 2-4 decent cores 1-2 vCPU
Setup clone a template manual config
Persistence workspace only full system
Legitimacy pushing ToS 100% clean

The funny thing is that on raw specs (RAM, CPU), the GitHub runner is often BETTER than a 5€ VPS. But the 6h uptime and workspace-only persistence are what make it a hacker toy, not a real server.

For learning, testing, quickly debugging a Linux thing in a recoverable environment? Perfect. For hosting anything serious? Get a real VPS.

But for a temporary Linux environment you can restore at will? It's just brilliant.


The pattern behind it all

If you zoom out, repo-to-vps and the email bot (my other article) are built on the same idea:

Git isn't just a version control system. It's a free, versioned, persistent storage system accessible via an API.

As soon as you have a stateless system (GitHub Actions, a Worker, a serverless function) and you want to keep state between executions, git can serve as a "disk."

  • The email bot stores a lastId in a git tag.
  • repo-to-vps stores an entire filesystem in a git branch.

Same pattern, two scales. A single value on one side, a full disk on the other.

And the git commit --amend + force-push is the common technique: you keep a single commit representing the current state, overwritten on every update. No bloating history, just a living snapshot.

It wasn't built for this. But it works. And it's free. And that's what's beautiful.


The 3 takeaways:

  1. A git branch = a persistent hard drive -- Store your filesystem in a dedicated branch, restore on startup, and you've got state that survives disposable machines.

  2. inotify + git = real-time autosave -- inotifywait watches changes at the kernel level and pushes to git instantly. With git commit --amend to keep a single clean commit.

  3. tmate turns a runner into a VPS -- Live SSH on a GitHub Actions machine, with auto-restart and one-command reconnection via the GitHub API.

Git as a hard drive, episode two. I think I'm gonna end up storing everything in git branches xD

Repo to VPS : transformer GitHub Actions en VPS gratuit avec stockage persistant

Comment transformer un runner GitHub Actions en VPS permanent avec git comme stockage persistant -- tmate, inotify et commit --amend.

GitHub te file un VPS gratuit pendant 6h. J'ai trouvé comment le rendre permanent.

GitHub Actions te donne des machines Linux gratuites.

Genre, des vrais serveurs Ubuntu. 2 cœurs, 7 Go de RAM, 14 Go de disque. Gratuit. Pendant 6h par run.

Le seul "problème" : à la fin du run, tout est effacé. La machine est jetable. Tu installes des trucs, tu codes, tu configures... et pouf, à la fin tout disparaît. Comme si t'avais rien fait.

Sauf si.

Sauf si tu utilises git comme disque dur.

Et là, d'un coup, t'as un VPS gratuit avec un disque persistant qui survit aux runs. Tu te reconnectes, tout est encore là. Tu reprends où tu t'étais arrêté.

C'est complètement pété. Laisse-moi t'expliquer xD


Le contexte : les runners GitHub Actions

Quand tu lances un workflow GitHub Actions, GitHub te file une VM.

C'est fait pour build ton code, lancer tes tests, deploy. Le workflow tourne, fait son taf, et la machine est détruite.

Mais rien ne t'empêche de faire autre chose avec cette VM. Genre, ouvrir un shell SSH dessus et l'utiliser comme un serveur.

Le truc, c'est que ces machines sont stateless et temporaires :

  • Temporaire : 6h max par run (timeout-minutes: 360, le plafond de GitHub)
  • Stateless : tout est effacé à la fin

Donc pour en faire un VPS utilisable, faut résoudre deux problèmes :

  1. Comment se connecter dessus en temps réel ?
  2. Comment garder le disque entre deux runs ?

Là ça devient un sale hack.


Problème 1 : le SSH live avec tmate

tmate c'est un fork de tmux qui crée une session SSH partageable.

Tu le lances sur une machine, il te génère deux liens :

Tu te connectes avec un de ces liens, et boom, t'es dans un shell sur la machine. En temps réel.

Le workflow lance donc tmate :

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# récupère les liens de connexion
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Et ces liens sont écrits direct dans le README du repo par un script Python. Tu ouvres ton repo, tu vois le lien de connexion, tu cliques. Te voilà dans ton VPS.

Premier problème réglé. Mais c'est le deuxième qui est vraiment fou.


Problème 2 : git comme disque dur

Voilà le truc de malade.

La machine est effacée à chaque run. Donc on stocke le système de fichiers dans une branche git dédiée, appelée filesystem.

Au démarrage, le script restore l'état depuis cette branche :

filesystem_branch="filesystem"

# récupère la branche filesystem depuis le remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restore le workspace depuis cette branche
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

La branche filesystem C'EST ton disque dur. Tes fichiers, tes installs, tes configs -- tout est dedans.

Tu vois le truc ? La machine est jetable, mais le disque vit dans git. Tu relances le workflow, le disque est restauré, tu reprends pile où t'en étais.

C'est comme un VPS qui hibernate. Sauf que l'hibernation c'est un repo git xD

Premier lancement : créer le disque vide

Au tout premier run, la branche filesystem existe pas encore. Faut la créer. Et c'est pas anodin :

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

Le git checkout --orphan c'est la clé. Une branche orpheline c'est une branche sans aucun historique -- comme si tu repartais d'un repo vide.

Pourquoi orpheline ? Parce que tu veux PAS que ton disque persistant traîne tout l'historique de ton code source. Le disque c'est un truc à part, qui a sa propre vie. Il commence vierge.

Et le git ls-remote --exit-code au début, c'est juste un check propre : "est-ce que la branche existe déjà sur le remote ?". Si oui, on touche à rien. Si non, on la crée. Idempotent, comme on aime.

Le git clean sélectif : protéger les caches

Cette ligne mérite qu'on s'arrête dessus :

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx ça vire TOUT ce qui est pas tracké par git. Normalement c'est violent -- ça nettoie le workspace à fond.

Mais les -e (exclude) protègent certains trucs :

  • .apt-cache → le cache des paquets APT (on y reviendra, c'est malin)
  • .cache → cache générique
  • host.conf → l'adresse SSH de la session
  • tmate.sock → le socket de la session tmate en cours

Si tu nettoyais ces fichiers-là, tu casserais la session active ou tu perdrais ton cache. Donc on les épargne pendant le reset.

Un détail con à première vue, mais sans ça tout pète.


L'autosave : inotify qui surveille tout

Bon, mais comment les fichiers se retrouvent dans la branche filesystem ?

Réponse : un watcher qui surveille TOUS les changements de fichiers et commit/push automatiquement.

L'outil magique c'est inotifywait (du paquet inotify-tools). Il surveille le filesystem au niveau du kernel et déclenche dès qu'un fichier change.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce si plein de changements d'un coup
  done
}

autosave &

Décortiquons les flags inotify, parce que chacun compte :

  • -r → récursif, surveille tous les sous-dossiers
  • -e modify,create,delete,move → réagit à ces 4 types d'événements
  • --exclude '...' → une regex pour ignorer certains fichiers

Le --exclude est crucial. Regarde ce qu'il ignore :

  • .git → évidemment, sinon chaque commit déclencherait un autosave qui déclencherait un commit... boucle infinie. Catastrophe.
  • .apt-cache et .cache → les caches, qui changent tout le temps et qu'on veut pas spammer dans git
  • host.conf et tmate.sock → les fichiers de session, qui bougent sans arrêt
  • .gitignore, .txt.swp → les fichiers temporaires (les .swp c'est les fichiers d'édition de vim)

Sans cet exclude, tu te retrouverais avec un autosave qui se déclenche en boucle sur ses propres changements. Le .git dans la liste, c'est LA ligne qui t'empêche de te tirer une balle dans le pied.

Tu modifies un fichier ? inotify le détecte instantanément, ça commit, ça push. En moins d'une seconde, ton changement est dans la branche filesystem.

Tu installes un truc, tu écris du code, tu touches une config -- tout est sauvegardé en temps réel, automatiquement, sans que tu fasses quoi que ce soit.

T'as littéralement un système de sauvegarde automatique du disque entier. Pété.

Le debounce : pas spammer git

Le sleep 1 après chaque save c'est un debounce.

Quand tu sauvegardes un fichier dans un éditeur, souvent ça génère plusieurs événements filesystem en rafale (création d'un fichier temp, rename, suppression de l'ancien...). Sans debounce, tu déclencherais 3-4 commits pour une seule sauvegarde.

Le sleep 1 dit : "attends une seconde après un save, le temps que la rafale se calme, avant de réécouter". Ça regroupe les changements rapprochés en un seul commit. Malin.

Et une sauvegarde périodique en plus

Au cas où inotify raterait un truc, y'a aussi un save toutes les 5 secondes :

periodic_save() {
  while true; do
    sync_from_remote   # récupère les changements distants éventuels
    sleep 5
    commit_and_push
  done
}

periodic_save &

Ceinture ET bretelles. On veut surtout pas perdre l'état du disque.


Le détail malin : un seul commit

Si tu commit à chaque changement de fichier, tu vas accumuler des milliers de commits. En une heure de session, ton historique git explose. Le repo devient énorme. C'est dégueulasse.

La solution est élégante : on amende le commit existant au lieu d'en créer un nouveau.

commit_and_push() {
  (
    flock -n 200 || return   # lock pour pas que deux saves tournent en même temps

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # touche pas aux scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : écrase le commit précédent
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend ça veut dire : "remplace le dernier commit par celui-là".

Du coup la branche filesystem a TOUJOURS un seul commit. Peu importe combien de fois tu sauvegardes. C'est juste un snapshot de l'état actuel, force-pushé encore et encore.

Le flock c'est un verrou : comme y'a deux boucles de save (inotify + périodique), faut éviter qu'elles lancent git en même temps et se marchent dessus. Un seul process git à la fois.

Propre.


Le sync_from_remote : gérer plusieurs sessions

Tiens, un truc auquel tu penses pas au début : et si tu lances DEUX runs en même temps ? Ou si une session modifie la branche filesystem pendant qu'une autre tourne ?

Le script gère ça avec un sync_from_remote avant chaque commit :

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

Le --ff-only (fast-forward only) c'est important : ça veut dire "merge UNIQUEMENT si on peut avancer proprement, sans créer de commit de merge".

Si les deux branches ont divergé (genre, deux sessions ont modifié des trucs différents), le fast-forward échoue silencieusement (2>/dev/null || true) et on garde l'état local. C'est pas un système de merge parfait, mais ça évite les corruptions dans le cas simple où une seule session tourne.

Honnêtement, faut pas lancer 3 sessions en parallèle sur le même repo. Mais le code essaie quand même de pas exploser si ça arrive. C'est de la défense.


Le cache APT : installer vite

Y'a un détail dans le workflow qui paye pas de mine mais qui est bien pensé :

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate et inotify-tools sont installés via une action qui cache les paquets APT.

Au premier run, ça télécharge et installe. Aux runs suivants, c'est restauré depuis le cache GitHub Actions -- plus rapide, pas besoin de re-télécharger.

Et tu te souviens du git clean -fdx -e .apt-cache de tout à l'heure ? C'est lié. Le dossier .apt-cache est protégé du nettoyage justement pour que les paquets que tu installes pendant ta session puissent persister un minimum.

Tout se tient. J'ai pensé au cycle de vie complet.


Les scripts planqués dans /tmp

Encore un détail vicieux mais malin. Au tout début du script :

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Les scripts (update_readme.py, etc.) sont copiés dans /tmp AVANT de toucher à la branche filesystem.

Pourquoi ? Parce que quand tu fais le git reset --hard vers la branche filesystem (qui est vide au début, ou qui contient ton disque), les fichiers .github/scripts du repo source disparaissent du workspace.

Mais le script en a encore besoin pendant la session (pour update le README à chaque relance de tmate). Donc il les planque dans /tmp, hors de portée de git :

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Si t'y penses pas, tu galères 30 minutes à comprendre pourquoi ton script a disparu. J'y ai pensé.


Le shell sur-mesure

Petit confort : la session te file un shell configuré, pas un bash tout nu.

Le prestart.sh copie un .bashrc custom :

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

Et ce .bashrc contient un prompt coloré, des alias (ll, lla, rm -i), et surtout un override de exit :

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D fait pareil que exit
bind -x '"\C-d": "exit"'

Quand tu tape exit (ou Ctrl+D), ça kill proprement les process tmate avant de fermer. Ça évite de laisser des sessions tmate zombies.

Y'a aussi une fonction tmate-detach si tu veux te déconnecter SANS tuer la session (pour te reconnecter plus tard). Détail de confort, mais ça montre le niveau de soin.


Le tmate qui se relance tout seul

Petit confort : si tu tape exit dans ton shell, normalement la session tmate meurt et t'es déconnecté pour de bon.

Sauf qu'ici, tmate est dans une boucle while true :

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

Tu exit ? La session redémarre toute seule. Tu te reconnectes avec le même lien.

C'est débile, mais ça rend le truc utilisable.


La reconnexion en une commande

Comment tu te reconnectes après une déco, sans aller fouiller dans les logs du run à chaque fois ?

L'adresse SSH de tmate est écrite dans un fichier host.conf, committé dans la branche filesystem :

printf '%s' "${tmate_ssh#ssh }" > host.conf

Et comme ce fichier est dans git, tu peux le récupérer via l'API GitHub avec une seule commande :

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Tu lance ça, ça va chercher l'adresse SSH actuelle dans le repo, et te connecte. Même si l'adresse a changé entre deux sessions.


Le flow complet

Récapitulons :

  1. Tu déclenche le workflow (push ou bouton manuel)
  2. GitHub te file une VM Ubuntu
  3. Le script restore le disque depuis la branche "filesystem"
  4. inotify commence à surveiller tous les changements
  5. periodic_save commit toutes les 5s en backup
  6. tmate démarre → génère les liens SSH/web
  7. Les liens sont écrits dans le README + host.conf
  8. Tu te connecte avec ssh ou le terminal web
  9. Tu fais ce que tu veux -- chaque changement de fichier = autosave
  10. 6h plus tard, GitHub tue la VM
  11. Ton disque est intact dans la branche "filesystem"
  12. Tu relance le workflow → retour à l'étape 3, tout est encore là

Un VPS gratuit avec disque persistant. Juste avec git et GitHub Actions.


Bon, faut être honnête : les limites

C'est un hack, pas un vrai VPS. Donc :

  • 6h max par run. Faut relancer le workflow régulièrement. Pas de uptime infini.
  • Pas pour de la prod. Tu vas pas héberger ton site là-dessus. C'est pour explorer, dev, debug, tester un truc dans un Linux jetable mais récupérable.
  • GitHub voit tout. C'est leurs machines. Mets rien de sensible.
  • Garde le repo privé. Tu exposes un shell SSH. Un repo public = n'importe qui peut potentiellement s'y connecter. Mauvaise idée.
  • C'est à la limite des conditions d'usage. GitHub Actions c'est fait pour de la CI/CD, pas pour du VPS gratuit. Donc à utiliser avec parcimonie, pour du légitime, sans abuser.

Le vrai talon d'Achille : git déteste les gros fichiers

Git c'est fait pour du texte, pas pour un filesystem.

Le disque persistant vit dans une branche git. Donc tout ce que tu sauvegardes passe par git. Et git :

  • gère mal les gros fichiers binaires (une image Docker de 2 Go dans git ? oublie)
  • a une limite de 100 Mo par fichier sur GitHub (hard limit, ça push pas au-delà)
  • recommande de rester sous ~5 Go par repo

Donc si tu npm install un projet avec 500 Mo de node_modules, ou si tu build un truc qui crache des binaires lourds, le push vers filesystem va soit ramer à mort, soit carrément échouer.

Le git commit --amend aide (un seul commit, pas d'historique qui gonfle), mais ça change rien au fait qu'un fichier de 200 Mo passera jamais.

En gros : ça marche super pour du code, des configs, des petits fichiers. Ça marche pas pour stocker des grosses données ou des artefacts binaires. Faut garder ça en tête sur ce que tu fais dans ta session.

C'est pas un snapshot système complet

Autre nuance importante : la branche filesystem sauvegarde le workspace (le dossier du repo), pas tout le système.

Si tu fais apt install htop, le binaire va dans /usr/bin/htop, qui est HORS du workspace. Donc il sera PAS sauvegardé. Au prochain run, faut le réinstaller.

C'est pour ça qu'on a le cache APT et le prestart.sh : pour re-préparer l'environnement système à chaque démarrage, vu que seul le workspace persiste.

Si tu veux que tes installs survivent, faut les mettre dans le workspace (genre, installer dans un dossier local plutôt qu'en système). C'est une gymnastique à intégrer.


VPS gratuit vs vrai VPS : le match

repo-to-vps Vrai VPS (5€/mois)
Prix 0€ ~5-10€/mois
Uptime 6h, à relancer 24/7
Disque branche git, petits fichiers vrai SSD, plusieurs Go
RAM ~7 Go (généreux !) 1-2 Go souvent
CPU 2-4 cœurs corrects 1-2 vCPU
Setup clone un template config manuelle
Persistance workspace seulement système complet
Légitimité limite des CGU 100% clean

Le truc marrant c'est que côté specs brutes (RAM, CPU), le runner GitHub est souvent MEILLEUR qu'un VPS à 5€. Mais l'uptime de 6h et la persistance limitée au workspace, c'est ce qui en fait un jouet de hacker, pas un vrai serveur.

Pour apprendre, tester, débugger un truc Linux vite fait dans un environnement récupérable ? Parfait. Pour héberger quoi que ce soit de sérieux ? Prends un vrai VPS.

Mais pour un environnement Linux temporaire que tu peux restaurer à volonté ? C'est juste génial.


Le pattern derrière tout ça

Si tu prends du recul, repo-to-vps et le bot email (mon autre article) reposent sur la même idée :

Git n'est pas qu'un gestionnaire de versions. C'est un système de stockage persistant, gratuit, versionné, accessible via une API.

Dès que t'as un système stateless (GitHub Actions, un Worker, une fonction serverless) et que tu veux garder un état entre deux exécutions, git peut servir de "disque".

  • Le bot email stocke un lastId dans un tag git.
  • repo-to-vps stocke un filesystem entier dans une branche git.

Même pattern, deux échelles. Une valeur d'un côté, un disque de l'autre.

Et le git commit --amend + force-push c'est la technique commune : tu gardes un seul commit qui représente l'état actuel, écrasé à chaque update.

C'est pas fait pour ça. Mais ça marche. Et c'est gratuit.


Les 3 trucs à retenir :

  1. Une branche git = un disque dur persistant -- Stocke ton filesystem dans une branche dédiée, restore au démarrage, et t'as un état qui survit aux machines jetables.

  2. inotify + git = autosave temps réel -- inotifywait surveille les changements au niveau kernel et push vers git instantanément. Avec git commit --amend pour garder un seul commit propre.

  3. tmate transforme un runner en VPS -- SSH live sur une machine GitHub Actions, avec restart automatique et reconnexion en une commande via l'API GitHub.

Git comme disque dur, deuxième épisode. Je crois que je vais finir par tout stocker dans des branches git xD

Repo to VPS:将GitHub Actions变成免费持久化VPS

如何将GitHub Actions runner变成永久VPS,用git作为持久化存储----tmate、inotify和commit --amend。

GitHub白送你6小时的免费VPS。我找到了让它永续的方法。

GitHub Actions会白送你Linux机器。

没错,真正的Ubuntu服务器。2核CPU、7GB内存、14GB硬盘。免费。每次运行6小时。

唯一的"问题":运行结束后,一切都被清空。机器是一次性的。你装软件、写代码、配环境……然后啪,全没了。好像你啥都没干过。

除非。

除非你把 git 当成硬盘来用。

这样一来,你就有了一个免费的VPS,带着一个能跨运行周期存活下来的持久硬盘。你重新连上,一切还在。你从上次停下的地方继续干。

这玩意彻底坏掉了。让我给你讲讲 xD


背景:GitHub Actions Runner

当你启动一个GitHub Actions工作流时,GitHub会白给你一台虚拟机。

它的本意是帮你编译代码、跑测试、部署的。工作流跑完,机器就被销毁。

但没人拦着你拿这台VM干别的。比如,在上面开一个SSH shell,当成服务器用。

问题是,这些机器是 无状态 和 临时 的:

  • 临时:每次运行最多6小时(timeout-minutes: 360,GitHub的上限)
  • 无状态:结束后一切归零

所以要把它变成一个可用的VPS,得解决两个问题:

  1. 怎么实时连上去?
  2. 怎么在两次运行之间保留硬盘数据?

这就是一个骚到爆的hack登场的地方了。


问题1:用tmate搞实时SSH

tmate 是tmux的一个分支,可以创建可分享的SSH会话。

你在机器上启动它,它会生成两个链接:

你用其中一个链接连上去,boom,就进到机器的shell了。实时的。

工作流就这样启动tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

这些链接会被一个Python脚本直接写进仓库的README里。你打开仓库,看到连接链接,一点击。你就进了你的VPS。

第一个问题搞定了。但第二个才是真正离谱的。


问题2:把git当硬盘用

这操作是真的牛批。

机器每次运行都会被清空。所以我们要把 整个文件系统存到一个专用的git分支里,叫做 filesystem。

启动时,脚本从那个分支恢复状态:

filesystem_branch="filesystem"

git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

filesystem 分支就是你的硬盘。你的文件、你装的东西、你的配置----全在里面。

你懂我意思吗?机器是一次性的,但硬盘活在git里。你重新启动工作流,硬盘被恢复,你直接从上次停下的地方继续。

就像一个能休眠的VPS。只不过休眠就是git仓库 xD

首次启动:创建空硬盘

在第一次运行的时候,filesystem分支还不存在。得创建它。而且这不是随便搞的:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan 是关键。孤儿分支就是一个 没有任何历史 的分支----就像从一个空仓库重新开始。

为什么要孤儿分支?因为你 不 想让你的持久硬盘带着你源代码的完整历史。硬盘是独立的东西,有自己的生命。它从空白开始。

开头的 git ls-remote --exit-code 只是一个干净检查:"远程上这个分支存在吗?" 存在就什么都不干。不存在就创建它。幂等操作。

选择性git clean:保护缓存

这一行值得停下来好好看看:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx 会删除所有不被git追踪的东西。一般来说这很暴力----它会彻底清理工作区。

但 -e(排除)保护了某些东西:

  • .apt-cache → APT包的缓存(后面会讲到,很聪明)
  • .cache → 通用缓存
  • host.conf → 会话的SSH地址
  • tmate.sock → 当前tmate会话的socket

如果你清理了这些文件,你就会断开当前会话或者丢掉缓存。所以我们在重置时放过了它们。

这种细节第一眼看不出来,但正是"能用"和"真好用"之间的区别。


自动保存:inotify监视一切

那么,文件是怎么进入 filesystem 分支的?

答案:一个监视器,监视 所有文件变更 并自动提交/推送。

这个魔法工具就是 inotifywait(来自 inotify-tools 包)。它在内核层面监视文件系统,一旦有文件变化就会触发。

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

我们来拆解一下inotify的flags,因为每个都很重要:

  • -r → 递归,监视所有子文件夹
  • -e modify,create,delete,move → 对这4种事件做出反应(修改、创建、删除、移动)
  • --exclude '...' → 一个正则表达式,用来忽略某些文件

--exclude 至关重要。看看它忽略了什么:

  • .git → 当然要忽略,不然每次提交都会触发自动保存,自动保存又触发提交……无限循环。灾难。
  • .apt-cache 和 .cache → 缓存,它们一直在变,你不想在git里刷屏吧
  • host.conf 和 tmate.sock → 会话文件,不停在变化
  • .gitignore、.txt.swp → 临时文件(.swp是vim的编辑文件)

没有这个exclude,自动保存就会在自己的变更上反复触发。.git 在你的exclude列表里,就是阻止你搬起石头砸自己脚的那一行。

你改了一个文件?inotify立刻检测到,提交,推送。不到一秒钟,你的变更就到了 filesystem 分支。

你装了个东西、写了段代码、改了个配置----一切都是实时、自动保存的,你什么都不用做。

你literally有了一个全盘自动保存系统。炸裂。

Debounce:别刷爆git

每次保存后的 sleep 1 就是一个 debounce。

当你在编辑器里保存文件时,往往会产生一连串文件系统事件(创建临时文件、重命名、删除旧文件……)。没有debounce,一次保存就会触发3-4次提交。

sleep 1 的意思是:"保存后等一秒钟,等这波事件平息了再继续监听"。它将邻近的变更合并成一次提交。聪明。

外加一个定期保存

以防inotify漏掉什么,还有个每5秒的定期保存:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

安全带加安全绳。我们可不想丢了硬盘状态。


一个小聪明:只有一个提交

如果你每次文件变更都提交,你会积累成千上万的提交。一小时的session下来,你的git历史炸了。仓库变得巨大。恶心。

解决方案很优雅:我们修改已有的提交,而不是创建新提交。

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend 的意思是:"用这个提交替换最后一个提交"。

所以 filesystem 分支 永远只有一个提交。不管你保存多少次。它只是当前状态的一个快照,被一遍又一遍地force-push。

flock 是一个锁:因为有两个保存循环(inotify + 定期),得防止它们同时操作git互相踩踏。一次只跑一个git进程。

干净。


sync_from_remote:处理多个会话

哎,有个你一开始想不到的事:如果你同时启动 两个 运行呢?或者一个会话在修改 filesystem 分支时另一个也在跑?

脚本用每个提交前的 sync_from_remote 来处理:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only(仅快进)很重要:意思是"只有能干净地前进时才合并,不创建合并提交"。

如果两个分支出现了分歧(比如两个会话改了不同的东西),快进会静默失败(2>/dev/null || true),保留本地状态。这不是一个完美的合并系统,但在只有一个会话运行的简单情况下,它能避免损坏。

说实话,你别在同一个仓库上同时开3个会话就是了。但代码还是尽量不让它炸掉。算是一种防御。


APT缓存:极速安装

工作流里有个不起眼但设计得很好的细节:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate和inotify-tools是通过一个 缓存APT包 的action安装的。

第一次运行时下载安装。之后的运行就从GitHub Actions缓存恢复----更快,不用重新下载。

还记得之前说的 git clean -fdx -e .apt-cache 吗?这是一起的。.apt-cache 文件夹受到保护不被清理,就是为了让你在session期间安装的包能尽量持久化。

一切都是有联系的。我考虑了完整的生命周期。


藏在/tmp里的脚本

又一个阴险但聪明的细节。在脚本的最开头:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

脚本(update_readme.py 等)在 碰 filesystem 分支之前 就被复制到了 /tmp。

为什么?因为当你执行 git reset --hard 切换到 filesystem 分支(一开始是空的,或者存着你的硬盘数据)时,源仓库里的 .github/scripts 文件会从工作区消失。

但脚本在session期间还需要它们(用来在每次tmate重启时更新README)。所以他把它们藏在 /tmp 里,git管不到的地方,方便之后调用:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

这种bug如果你没想到会在你脸上炸开:"为什么我的脚本不见了?"。我想到了。


定制Shell

最后一点小舒适:你的session得到一个配好环境的shell,而不是裸bash。

prestart.sh 复制了一个自定义 .bashrc:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

这个 .bashrc 包含彩色提示符、别名(ll、lla、rm -i),还有一个最关键的小聪明:exit 的覆盖:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

当你输入 exit(或Ctrl+D)时,它会先干净地杀掉tmate进程再退出。这防止了机器上残留僵尸tmate会话。

还有一个 tmate-detach 函数,如果你想 断开连接但不杀死会话(方便之后重连)。小舒适,但体现了用心程度。


自动重启的tmate

小舒适:如果你在shell里输入 exit,正常情况下tmate会死掉,你就永久断开了。

但在这里,tmate在一个 while true 循环里:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

你 exit 了?会话自动重启。用同一个链接重新连上。

这很蠢,但让这东西能用。


一条命令重连

怎么在断开后重连,而不用每次都去翻run的日志?

tmate的SSH地址被写在一个 host.conf 文件里,而这个文件又被提交到了 filesystem 分支:

printf '%s' "${tmate_ssh#ssh }" > host.conf

由于这个文件在git里,你可以通过GitHub API用一条命令获取它:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

你跑这条命令,它去仓库里查找当前的SSH地址,然后直接连上。即使地址在两次会话之间变了也没问题。

丝滑得一匹。


完整流程

我们来总结一下整件事:

1. 你触发工作流(push或手动按钮)
2. GitHub给你一台Ubuntu VM
3. 脚本从"filesystem"分支恢复硬盘
4. inotify开始监视所有变更
5. periodic_save每5秒备份提交一次
6. tmate启动 → 生成SSH/Web链接
7. 链接被写入README + host.conf
8. 你用ssh或web终端连上
9. 你想干嘛干嘛(写代码、装东西、调试)
   └── 每次文件变更 = 自动保存到git
10. 6小时后,GitHub杀掉VM
11. 但你的硬盘完好无损地留在"filesystem"分支里
12. 你重新启动工作流 → 回到第3步,一切还在

一个VPS。免费的。带持久硬盘。全靠git和GitHub Actions。


得说实话:局限性

这是个hack,不是真正的VPS。所以:

  • 每次运行最多6小时。 得定期重新启动工作流。没有无限uptime。
  • 不能用于生产环境。 你不会在上面托管网站的。这是用来探索、开发、调试、在可恢复的一次性Linux环境里测试东西的。
  • GitHub什么都能看到。 这是他们的机器。别放敏感数据。
  • 保持仓库私有。 你暴露了一个SSH shell。公开仓库 = 任何人都可能连上去。坏主意。
  • 这游走在服务条款的边缘。 GitHub Actions是给CI/CD用的,不是给免费VPS的。所以悠着点用,干正经事,别滥用。

真正的致命弱点:git讨厌大文件

有一个更技术性的限制,也是最重要的一个。

git是为文本设计的,不是为文件系统设计的。

持久硬盘活在一个git分支里。所以你保存的一切都要经过git。而git:

  • 处理大二进制文件很糟糕(一个2GB的Docker镜像放git里?别想了)
  • GitHub上有每个文件100MB的硬限制(超出就推不上去)
  • 建议每个仓库保持在~5GB以下

所以如果你 npm install 一个带500MB node_modules 的项目,或者你build了一个生成大二进制文件的东西,推送到 filesystem 要么慢得要死,要么直接失败。

git commit --amend 有帮助(只有一个提交,历史不会膨胀),但改变不了200MB的文件永远过不去的事实。

总之:干代码、配置文件、小文件完美。存大数据或二进制产物不行。 在你在session里干什么的时候要记住这一点。

这不是完整的系统快照

另一个重要的细节:filesystem 分支保存的是 工作区(仓库目录),而不是整个系统。

如果你 apt install htop,二进制文件去了 /usr/bin/htop,它在 工作区之外。所以它 不会 被保存。下次运行你得重新安装。

这就是为什么我们有APT缓存和 prestart.sh:为了在每次启动时重新准备系统环境,因为只有工作区是持久化的。

如果你想让安装的东西持久化,你得把它们放在工作区里(比如装到本地目录而不是系统目录)。这是一种需要适应的操作方式。


免费VPS vs 真VPS:对决

repo-to-vps 真VPS(5€/月)
价格 0€ ~5-10€/月
在线时间 6小时,需重启 24/7
硬盘 git分支,小文件 真SSD,多GB
内存 ~7GB(超大方!) 通常1-2GB
CPU 2-4核,还不错 1-2 vCPU
搭建 克隆模板 手动配置
持久化 仅工作区 完整系统
合法性 接近条款边缘 100%合规

有意思的是,在原始规格(内存、CPU)上,GitHub runner通常比5€的VPS 更好。但6小时的运行时间上限和仅限于工作区的持久化,让它成为一个hacker的玩具,而不是真正的服务器。

用来学习、测试、快速调试Linux环境?完美。用来托管任何正经的东西?买个真VPS吧。

但作为一个你可以随意恢复的临时Linux环境?这玩意就是神。


背后的模式

如果你退一步看,repo-to-vps和邮件bot(我的另一篇文章)基于同一个想法:

Git不只是一个版本管理器。它是一个持久的、免费的、带版本控制的、可通过API访问的存储系统。

一旦你有了一个无状态的系统(GitHub Actions、Worker、serverless函数),又想在两次执行之间保持状态,git就可以充当"硬盘"。

  • 邮件bot把一个 lastId 存在git tag里。
  • repo-to-vps把整个文件系统存在git分支里。

同一个模式,两种规模。一边是一个值,另一边是一个硬盘。

而 git commit --amend + force-push 是共同的技术:你只保留一个代表当前状态的提交,每次更新都会覆盖。 历史不膨胀,只有一个活着的快照。

这不是它原本的用途。但它能用。而且是免费的。这才是最美的地方。


3个要点:

  1. 一个git分支 = 一个持久硬盘 -- 把你的文件系统存在专用分支里,启动时恢复,你就在一次性机器上有了一个可存活的状态。

  2. inotify + git = 实时自动保存 -- inotifywait 在内核级别监视变更并即时推送到git。用 git commit --amend 保持只有一个干净的提交。

  3. tmate把runner变成VPS -- 在GitHub Actions机器上实时SSH,自动重启,一条命令通过GitHub API重连。

git当硬盘用,第二集。我觉得我最终会把所有东西都存在git分支里 xD

Repo to VPS:GitHub Actionsを無料の永続VPSにする方法

GitHub Actionsランナーを永続的なVPSに変える方法----gitをストレージとして使い、tmate、inotify、commit --amendを活用。

GitHubが6時間だけ無料のVPSをくれる。俺はそれを永久にする方法を見つけた。

GitHub Actionsが無料のLinuxマシンをくれるんだ。

そう、本物のUbuntuサーバーを。2コア、7GB RAM、14GBディスク。無料。1回のrunにつき6時間。

唯一の「問題」:runが終わると全部消える。マシンは使い捨て。何かインストールして、コード書いて、設定して…そしてぱっ、終わると全部消える。何もなかったかのように。

ただし。

gitをハードディスクとして使うなら別だ。

そして突然、永続ディスク付きの無料VPSが手に入る。runが終わっても生き残る。再接続すれば、全部まだある。続きから再開できる。

完全にぶっ壊れてる。説明させてくれ xD


背景:GitHub Actionsランナー

GitHub Actionsのワークフローを起動すると、GitHubがVMをくれる。

コードをビルドして、テストして、デプロイするためのもの。ワークフローが動いて仕事をして、マシンは破棄される。

でも、そのVMで別のことだってできる。SSHシェルを開いてサーバーとして使うとか。

ただ、これらのマシンは ステートレス で 一時的 だ:

  • 一時的:1runあたり最大6時間(timeout-minutes: 360、GitHubの上限)
  • ステートレス:終わると全部消える

だからVPSとして使えるようにするには、2つの問題を解決する必要がある:

  1. リアルタイムでどうやって接続するか?
  2. run間でディスクをどうやって保持するか?

ここからが汚い天才ハックの始まりだ。


問題1:tmateでライブSSH

tmate はtmuxのフォークで、共有可能なSSHセッションを作る。

マシン上で起動すると、2つのリンクを生成する:

どちらかのリンクで接続すれば、boom、マシンのシェルに入れる。リアルタイムで。

ワークフローがtmateを起動する:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

これらのリンクはPythonスクリプトで直接リポジトリのREADMEに書き込まれる。リポジトリを開いて、接続リンクを見て、クリックする。VPSにログイン完了。

最初の問題は解決。でも2つ目が本当にヤバい。


問題2:gitをハードディスクとして使う

これが狂ったやつ。

runごとにマシンは消される。だから ファイルシステムを専用のgitブランチ(filesystem という名前)に保存する。

起動時に、スクリプトがそのブランチから状態を復元する:

filesystem_branch="filesystem"

git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

filesystem ブランチがお前のハードディスクだ。ファイル、インストールしたもの、設定 -- 全部そこにある。

わかるか?マシンは使い捨てだが、ディスクはgitの中に生きている。ワークフローを再実行すれば、ディスクが復元されて、続きから再開できる。

ハイバネートするVPSみたいなもの。ただしハイバネーションはgitリポジトリだ xD

最初の起動:空のディスクを作成

最初のrunでは、filesystemブランチはまだ存在しない。作る必要がある。しかもそれは簡単じゃない:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan が鍵だ。孤立ブランチとは 履歴がまったくない ブランチ -- 空のリポジトリからやり直すようなもの。

なぜ孤立ブランチか?永続ディスクにソースコードの全履歴を引きずらせたくないからだ。ディスクは独立したもので、独自の人生がある。真っさらから始まる。

git ls-remote --exit-code は単なるクリーンなチェック:「このブランチはリモートに既にあるか?」あれば何もしない。なければ作る。冪等性。

選択的git clean:キャッシュを守る

この行は注目に値する:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx はgitで追跡されてないものを全部消す。通常は暴力的だ -- ワークスペースを完全に掃除する。

でも -e(除外)がいくつかのものを守る:

  • .apt-cache → APTパッケージのキャッシュ(後で出てくる、賢いやつだ)
  • .cache → 汎用キャッシュ
  • host.conf → セッションのSSHアドレス
  • tmate.sock → 現在のtmateセッションのソケット

これらのファイルを掃除すると、アクティブなセッションを壊すかキャッシュを失う。だからリセット中は除外する。

一目見ただけでは気づかないけど、「動く」と「本当に動く」の差を生む細かい配慮だ。


オートセーブ:すべてを監視するinotify

でも、どうやってファイルが filesystem ブランチに入るのか?

答え:すべてのファイル変更を監視して自動的にcommit/pushするウォッチャー。

魔法のツールは inotifywait(inotify-tools パッケージ)。カーネルレベルでファイルシステムを監視し、ファイルが変更されるとトリガーする。

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

inotifyのフラグを分解してみよう、それぞれに意味がある:

  • -r → 再帰的、すべてのサブディレクトリを監視
  • -e modify,create,delete,move → この4種類のイベントに反応(変更、作成、削除、移動)
  • --exclude '...' → 特定のファイルを無視する正規表現

--exclude は極めて重要。何を無視しているか見てみよう:

  • .git → 当然だ。さもないとcommitごとにautosaveが発動し、それがまたcommitを発動して…無限ループ。大惨事。
  • .apt-cache と .cache → 頻繁に変わるキャッシュ。gitに大量に送りたくない。
  • host.conf と tmate.sock → 絶えず変化するセッションファイル
  • .gitignore、.txt.swp → 一時ファイル(.swp はvimの編集ファイル)

このexcludeがなければ、autosaveが自分の変更でループすることになる。リストの中の .git は、自爆を防ぐための最重要ラインだ。

ファイルを変更する?inotifyが瞬時に検出して、commit、push。1秒も経たないうちに、変更が filesystem ブランチに反映される。

何かをインストールする、コードを書く、設定をいじる -- すべてリアルタイムで自動保存される。何もする必要はない。

文字通りディスク全体の自動保存システムだ。ヤバい。

デバウンス:gitをスパムしない

各保存後の sleep 1 は デバウンス だ。

エディタでファイルを保存すると、複数のファイルシステムイベントが一気に発生することが多い(一時ファイル作成、リネーム、古いファイル削除…)。デバウンスなしだと、1回の保存で3〜4回のcommitが発生する。

sleep 1 はこう言っている:「保存後は1秒待って、バーストが収まるのを待ってから、次の監視を再開する」。近接した変更を1つのcommitにまとめる。賢い。

さらに定期的な保存も

inotifyが何かを逃した場合に備えて、5秒ごとの保存もある:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

二重の安全策。ディスクの状態を絶対に失いたくない。


賢い詳細:たった1つのcommit

ファイルが変わるたびにcommitすると数千のcommitが溜まる。1時間のセッションでgit履歴が爆発する。リポジトリが巨大になる。汚い。

解決策はエレガント:新しいcommitを作る代わりに、既存のcommitをamendする。

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend は「最後のcommitをこれで置き換える」という意味だ。

つまり filesystem ブランチは常に1つのcommitしか持たない。何回保存しても同じだ。単に現在の状態のスナップショットで、何度もforce-pushされる。

flock はロックだ:2つの保存ループ(inotify + 定期)があるから、同時にgitを実行して衝突するのを防ぐ。一度に1つのgitプロセスだけ。

クリーンだ。


sync_from_remote:複数セッションの処理

ああ、最初は思いつかないこと:もし2つのrunを同時に起動したら?または、あるセッションが filesystem ブランチを変更している間に別のセッションが動いていたら?

スクリプトは各commitの前に sync_from_remote でこれを処理する:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only(fast-forward only)が重要:「マージコミットを作らずに、きれいに進められる場合だけマージする」という意味だ。

2つのブランチが分岐した場合(例えば2つのセッションが別々のものを変更した)、fast-forwardは静かに失敗し(2>/dev/null || true)、ローカル状態を維持する。完璧なマージシステムではないが、1つのセッションだけが動いている単純なケースでは破損を防げる。

正直、同じリポジトリで3つのセッションを並行実行するべきじゃない。でもコードはそれでも爆発しないようにしようとしている。防御だ。


APTキャッシュ:高速インストール

ワークフローの中に、地味だけどよく考えられた詳細がある:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmateとinotify-toolsは APTパッケージをキャッシュする アクションを使ってインストールされる。

最初のrunではダウンロードしてインストール。以降のrunではGitHub Actionsのキャッシュから復元 -- 高速で、再ダウンロード不要。

さっきの git clean -fdx -e .apt-cache を覚えてる?それと関連してる。.apt-cache ディレクトリは、セッション中にインストールしたパッケージが最低限永続化できるように、掃除から保護されている。

全部が繋がってる。ライフサイクル全体を考えてた。


/tmpに隠されたスクリプト

またしても狡猾で賢い詳細。スクリプトの最初の方で:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

スクリプト(update_readme.py など)は filesystem ブランチに触る前に /tmp にコピーされる。

なぜか?git reset --hard を filesystem ブランチ(最初は空、またはディスクの中身が入っている)に対して実行すると、ソースリポジトリの .github/scripts ファイルがワークスペースから消えるからだ。

でもスクリプトはセッション中も必要だ(tmateが再起動するたびにREADMEを更新するため)。だから /tmp に隠して、gitの手の届かないところに置き、後で呼び出せるようにする:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

考えないとぶち当たるバグだ:「なんでスクリプトが消えたんだ?」自分は考えてた。


カスタムシェル

最後の小さな快適さ:セッションは設定済みのシェルをくれる。素のbashじゃない。

prestart.sh がカスタム .bashrc をコピーする:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

この .bashrc にはカラフルなプロンプト、エイリアス(ll、lla、rm -i)、そして何より賢い仕掛けが入ってる:exit のオーバーライド:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

exit(またはCtrl+D)を打つと、閉じる前にtmateプロセスをきれいにkillする。マシンにゾンビtmateセッションが残るのを防ぐ。

セッションを殺さずに切断したい場合(後で再接続するため)の tmate-detach 関数もある。快適さのためだが、気配りのレベルの高さがわかる。


自動再起動するtmate

ちょっとした快適さ:シェルで exit と打つと、普通はtmateセッションが死んで完全に切断される。

でもここでは、tmateは while true ループの中にある:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

exit した?セッションが自動で再起動する。同じリンクで再接続できる。

馬鹿げてるけど、これで使えるものになる。


ワンコマンドでの再接続

切断後、毎回runのログを探しまわらずにどうやって再接続するのか?

tmateのSSHアドレスは host.conf ファイルに書き込まれ、それ自身が filesystem ブランチにコミットされる:

printf '%s' "${tmate_ssh#ssh }" > host.conf

このファイルはgitにあるから、GitHub APIを使って1つのコマンドで取得できる:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

これを実行すると、リポジトリから現在のSSHアドレスを取得して、直接接続する。セッション間でアドレスが変わっても大丈夫。

超スムーズだ。


完全な流れ

全体をおさらいしよう:

1. ワークフローを起動する(pushまたは手動ボタン)
2. GitHubがUbuntu VMをくれる
3. スクリプトが "filesystem" ブランチからディスクを復元
4. inotifyがすべての変更を監視開始
5. periodic_saveが5秒ごとにバックアップcommit
6. tmate起動 → SSH/Webリンクを生成
7. リンクがREADME + host.confに書き込まれる
8. SSHまたはWebターミナルで接続
9. 好きなことをする(コーディング、インストール、デバッグ)
   └── ファイル変更ごと = gitへの即時autosave
10. 6時間後、GitHubがVMを停止
11. でもディスクは "filesystem" ブランチに無傷で残っている
12. ワークフローを再実行 → ステップ3に戻る、全部まだある

VPS。無料。永続ディスク付き。gitとGitHub Actionsだけで。


正直になろう:限界

これはハックであって、本物のVPSではない。だから:

  • 1runあたり最大6時間。 定期的にワークフローを再実行する必要がある。無限のアップタイムはない。
  • 本番用ではない。 そこにサイトをホストしたりしない。探索、開発、デバッグ、使い捨てだけど復元可能なLinuxで何か試すためのものだ。
  • GitHubはすべてを見ている。 彼らのマシンだ。機密情報は置くな。
  • リポジトリは非公開にしろ。 SSHシェルを公開している。公開リポジトリ = 誰でも接続できる可能性がある。悪いアイデアだ。
  • 利用規約のグレーゾーンだ。 GitHub ActionsはCI/CDのためであり、無料VPSのためではない。控えめに、正当な目的で、乱用せずに使うこと。

本当のアキレス腱:gitは大容量ファイルが苦手

もっと技術的な限界があって、これが一番理解すべき重要事項だ。

gitはテキスト用であり、ファイルシステム用ではない。

永続ディスクはgitブランチの中に存在する。つまり保存するものはすべてgitを通る。そしてgitは:

  • 大きなバイナリファイルを苦手とする(2GBのDockerイメージをgitに?無理)
  • GitHubでは1ファイルあたり100MBの制限がある(ハードリミット、それ以上はpushできない)
  • リポジトリ全体で〜5GB以下を推奨

つまり、500MBの node_modules があるプロジェクトで npm install したり、大きなバイナリを生成するものをビルドしたりすると、filesystem へのpushが激しく遅くなるか、完全に失敗する。

git commit --amend は役立つ(1つのcommitだけ、履歴が膨らまない)が、200MBのファイルが通らないことに変わりはない。

要するに:コード、設定、小さなファイルには最高に使える。大きなデータやバイナリアーティファクトの保存には使えない。 セッションで何をするか、それを頭に入れておく必要がある。

完全なシステムスナップショットではない

もう1つの重要なニュアンス:filesystem ブランチが保存するのは ワークスペース(リポジトリのディレクトリ)だけで、システム全体ではない。

apt install htop を実行すると、バイナリは /usr/bin/htop に行く。これはワークスペースの外だ。だから保存されない。次のrunでは再インストールが必要だ。

だからAPTキャッシュと prestart.sh がある:システム環境を毎回再準備するためだ。永続化するのはワークスペースだけだから。

インストールしたものを残したいなら、ワークスペース内に入れる必要がある(システム全体ではなくローカルディレクトリにインストールするなど)。それは慣れる必要がある考え方の切り替えだ。


無料VPS vs 本物のVPS:対決

repo-to-vps 本物のVPS(月5€)
価格 0€ 〜5-10€/月
アップタイム 6時間、再起動必要 24/7
ディスク gitブランチ、小さいファイル 本物のSSD、数GB
RAM 〜7GB(太っ腹!) よくて1-2GB
CPU 2-4コア(良好) 1-2 vCPU
セットアップ テンプレートをクローン 手動設定
永続性 ワークスペースのみ 完全なシステム
正当性 利用規約の境界 100%クリーン

面白いのは、生のスペック(RAM、CPU)では、GitHubランナーが5€のVPSより優れていることが多いことだ。でも6時間のアップタイム制限とワークスペース限定の永続性が、これをハッカーのおもちゃにしていて、本物のサーバーにはしていない。

素早くLinuxの何かを学んだり、テストしたり、デバッグしたり、復元可能な環境でやるには?完璧。何か真面目なものをホストするため?本物のVPSを買え。

でも、好きな時に復元できる一時的なLinux環境としては?ただただ素晴らしい。


この背後にあるパターン

一歩引いて見ると、repo-to-vpsとメールボット(俺の別の記事)は同じアイデアに基づいている:

gitは単なるバージョン管理システムではない。無料で、バージョン管理され、APIからアクセス可能な永続ストレージシステムだ。

ステートレスなシステム(GitHub Actions、Worker、サーバーレス関数)があって、実行間で状態を保持したいなら、gitを「ディスク」として使える。

  • メールボットは lastId をgitタグに保存する。
  • repo-to-vpsはファイルシステム全体をgitブランチに保存する。

同じパターン、2つのスケール。一方は値、もう一方はディスク。

そして git commit --amend + force-push が共通のテクニックだ:現在の状態を表す1つのcommitを維持し、更新ごとに上書きする。 履歴が膨らまず、生きたスナップショットだけがある。

本来はこういう用途じゃない。でも動く。しかも無料だ。そしてそれが美しい。


覚えておくべき3つのこと:

  1. gitブランチ = 永続ハードディスク -- ファイルシステムを専用ブランチに保存し、起動時に復元すれば、使い捨てマシンを超えて生き残る状態が手に入る。

  2. inotify + git = リアルタイム自動保存 -- inotifywait がカーネルレベルの変更を監視し、即座にgitにpushする。git commit --amend で1つのクリーンなcommitを維持。

  3. tmateがランナーをVPSに変える -- GitHub ActionsマシンへのライブSSH。自動再起動と、GitHub API経由のワンコマンド再接続付き。

gitをハードディスクとして、第二弾。いつか全部gitブランチに保存するようになりそうだ xD

Repo to VPS: GitHub Actions를 무료 영구 VPS로 바꾸는 방법

GitHub Actions 러너를 git을 영구 저장소로 사용하여 항상 커져 있는 VPS로 바꾸는 방법 -- tmate, inotify, commit --amend.

GitHub가 6시간 동안 공짜 VPS를 준다. 영구적으로 만드는 법을 찾았음.

GitHub Actions가 공짜 Linux 머신을 줘.

그니까, 진짜 Ubuntu 서버임. 2코어, 7GB RAM, 14GB 디스크. 공짜. 실행당 6시간.

유일한 "문제"는: 실행이 끝나면 모든 게 지워진다는 거야. 머신은 일회용이야. 뭐 설치하고, 코딩하고, 설정하고, 그리고 푸, 끝나면 다 사라져. 아무것도 한 게 없는 것처럼.

근데 말이지.

근데 git을 하드디스크처럼 쓰면 말이지.

그러면 갑자기, 실행이 끝나도 살아있는 영구 디스크를 가진 공짜 VPS가 생기는 거야. 다시 접속해도 모든 게 그대로 있어. 중단했던 곳부터 다시 시작하면 돼.

완전 개쩔어. 설명해줄게 xD


배경: GitHub Actions 러너

GitHub Actions 워크플로우를 실행하면, GitHub가 VM을 하나 줘.

원래는 코드 빌드하고, 테스트 돌리고, 배포하라고 있는 거야. 워크플로우가 돌고, 일 끝나면 머신은 파괴됨.

근데 아무도 못하게 하는 건 없어. 이 VM으로 다른 걸 하는 걸. 예를 들어, SSH 셸을 열어서 서버처럼 쓰는 거.

요점은, 이 머신들은 stateless이고 임시라는 거야:

  • 임시: 실행당 최대 6시간 (timeout-minutes: 360, GitHub 상한선)
  • Stateless: 끝나면 다 지워짐

그래서 이걸 쓸만한 VPS로 만들려면 두 가지 문제를 해결해야 해:

  1. 실시간으로 어떻게 접속할까?
  2. 실행 사이에 디스크를 어떻게 유지할까?

여기서부터 레전드 핵짓이 시작됨.


문제 1: tmate로 실시간 SSH

tmate는 tmux의 포크인데, 공유 가능한 SSH 세션을 만들어줘.

머신에서 실행시키면 두 개의 링크가 생성돼:

이 링크 중 하나로 접속하면, 바로 머신의 셸에 들어가지는 거야. 실시간으로.

워크플로우가 tmate를 실행시키는 법:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

그리고 이 링크들은 Python 스크립트로 repo의 README에 바로 써져. repo 열면 접속 링크가 보이고, 클릭하면 돼. 바로 VPS에 접속 완료.

첫 번째 문제 해결. 근데 두 번째가 진짜 미친 거임.


문제 2: git을 하드디스크처럼

여기서부터 개쩌는 거임.

머신은 실행될 때마다 지워져. 그래서 파일시스템을 filesystem이라는 전용 git 브랜치에 저장하는 거야.

시작할 때, 스크립트가 이 브랜치에서 상태를 복원해:

filesystem_branch="filesystem"

git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

filesystem 브랜치가 니 하드디스크야. 파일, 설치한 거, 설정 -- 전부 다 여기 있어.

이해됨? 머신은 일회용이지만, 디스크는 git 안에 사는 거야. 워크플로우 다시 실행하면 디스크가 복원되고, 중단했던 데서 바로 계속할 수 있어.

최대절전모드 되는 VPS 같은 거야. 근데 최대절전모드가 git repo라는 게 다름 xD

첫 실행: 빈 디스크 만들기

처음 실행할 땐 filesystem 브랜치가 아직 없어. 만들어야 하는데, 이게 만만치 않음:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan이 핵심이야. Orphan 브랜치는 역사가 하나도 없는 브랜치야 -- 빈 repo에서 다시 시작하는 것과 같아.

왜 orphan이냐고? 니 영구 디스크에 소스 코드 전체 역사를 끌고 오고 싶지 않으니까. 디스크는 별개의 것이고, 자기만의 삶이 있어. 깨끗하게 시작하는 거야.

앞의 git ls-remote --exit-code는 그냥 깔끔한 체크야: "이 브랜치가 remote에 이미 있나?" 있으면 건드리지 않음. 없으면 만듦. 멱등성.

선택적 git clean: 캐시 보호하기

이 줄은 좀 자세히 볼 가치가 있어:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx는 git이 추적 안 하는 건 다 지워버려. 보통은 존나 거친 거야 -- workspace를 싹 밀어버림.

근데 -e (exclude)로 몇 가지는 보호해:

  • .apt-cache → APT 패키지 캐시 (뒤에 다시 나옴, 똑똑한 거임)
  • .cache → 일반 캐시
  • host.conf → 세션의 SSH 주소
  • tmate.sock → 현재 tmate 세션 소켓

이 파일들을 지우면, 활성 세션을 망가뜨리거나 캐시를 잃게 돼. 그래서 리셋할 때 얘네는 살려두는 거야.

언뜻 보면 모르는 디테일이지만, "그냥 되는" 거랑 "진짜 되는" 거를 가르는 차이임.


오토세이브: 모든 걸 감시하는 inotify

자, 그럼 파일들이 어떻게 filesystem 브랜치에 들어가는 걸까?

답변: 모든 파일 변경을 감시하고 자동으로 commit/push 하는 watcher.

마법의 도구는 inotifywait (inotify-tools 패키지). 커널 수준에서 파일시스템을 감시하고, 파일이 변경되면 바로 트리거됨.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

inotify 플래그를 하나씩 뜯어보자, 왜냐면 각각 의미가 있으니까:

  • -r → 재귀적, 모든 하위 폴더 감시
  • -e modify,create,delete,move → 이 4가지 이벤트 유형에 반응 (수정, 생성, 삭제, 이동)
  • --exclude '...' → 특정 파일을 무시하는 정규식

--exclude가 중요해. 뭘 무시하는지 보자:

  • .git → 당연히, 아니면 commit할 때마다 autosave가 트리거되고, 그게 또 commit을 트리거하고... 무한 루프. 재앙.
  • .apt-cache랑 .cache → 캐시, 계속 변하고 git에 spam하고 싶지 않은 것들
  • host.conf랑 tmate.sock → 세션 파일, 계속 바뀜
  • .gitignore, .txt.swp → 임시 파일 (.swp는 vim 편집 파일)

이 exclude가 없으면, autosave가 자기 변경사항에 계속 트리거되는 지옥이 펼쳐져. 목록에 있는 .git이 바로 니 발에 총 쏘는 걸 막아주는 줄임.

파일 하나 수정하면? inotify가 즉시 감지하고, commit하고, push함. 1초도 안 돼서 변경사항이 filesystem 브랜치에 들어가.

뭐 설치하고, 코드 쓰고, 설정 건드리면 -- 모든 게 실시간으로 자동 저장돼. 니가 아무것도 안 해도.

말 그대로 디스크 전체의 자동 백업 시스템이 있는 거야. 개쩔지.

디바운스: git 스팸 방지

매 save 후 sleep 1은 디바운스야.

에디터에서 파일을 저장하면, 보통 파일시스템 이벤트가 여러 개 연속으로 발생해 (임시 파일 생성, 이름 변경, 예전 거 삭제...). 디바운스 없으면 저장 한 번에 3-4개의 commit이 생겨.

sleep 1은 "save 후 1초 기다려, 연속 이벤트가 진정될 때까지, 그 다음에 다시 감시해"라는 뜻이야. 가까운 시간의 변경사항을 하나의 commit으로 묶는 거지. 똑똑해.

주기적 저장도 추가로

inotify가 놓칠 경우를 대비해서, 5초마다 저장하는 것도 있어:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

안전벨트에 멜빵까지. 디스크 상태는 절대 잃고 싶지 않으니까.


똑똑한 디테일: 단일 commit

파일이 바뀔 때마다 commit하면 수천 개의 commit이 쌓일 거야. 한 시간 세션만 해도 git 역사가 폭발해. repo가 엄청나게 커짐. 개더러움.

해결책은 우아해: 새 commit을 만드는 대신 기존 commit을 수정(amend)하는 거야.

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend는 "마지막 commit을 이걸로 대체해"라는 뜻이야.

그래서 filesystem 브랜치에는 항상 commit이 하나만 있어. 아무리 많이 저장해도. 그냥 현재 상태의 스냅샷을 계속 force-push 하는 거야.

flock은 잠금 장치야: 저장 루프가 두 개(inotify + 주기적) 있으니까, 동시에 git을 실행해서 서로 충돌하는 걸 방지하는 거야. 한 번에 하나의 git 프로세스만.

깔끔해.


sync_from_remote: 여러 세션 처리하기

자, 처음에 생각 못 하는 거: 두 개의 실행을 동시에 하면? 또는 한 세션이 filesystem 브랜치를 수정하는 동안 다른 세션이 돌아가면?

스크립트는 commit 전에 sync_from_remote로 이걸 처리해:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only)가 중요해: "merge commit을 만들지 않고 깔끔하게 진행할 수 있을 때만 MERGE 한다"는 뜻이지.

두 브랜치가 갈라졌으면 (예: 두 세션이 다른 걸 수정함), fast-forward는 조용히 실패하고(2>/dev/null || true) 로컬 상태를 유지해. 완벽한 merge 시스템은 아니지만, 세션이 하나만 돌아가는 간단한 경우에는 손상을 방지해줘.

솔직히, 같은 repo에서 3개 세션을 동시에 돌리면 안 됨. 그래도 코드는 그래도 폭발하지 않으려고 시도는 해. 방어적인 거지.


APT 캐시: 빠르게 설치하기

워크플로우에 눈에 띄지 않지만 잘 설계된 디테일이 하나 있어:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate랑 inotify-tools는 APT 패키지를 캐싱하는 액션으로 설치돼.

처음 실행 시 다운로드해서 설치함. 이후 실행에서는 GitHub Actions 캐시에서 복원됨 -- 더 빠르고, 다시 다운로드할 필요 없음.

아까 git clean -fdx -e .apt-cache 기억나? 연결되는 거야. .apt-cache 폴더가 청소에서 보호되는 이유는, 세션 중에 설치한 패키지가 최소한 유지되도록 하기 위해서야.

전부 다 연결되어 있어. 전체 라이프사이클을 생각했어.


/tmp에 숨겨진 스크립트들

또 하나 교활하지만 똑똑한 디테일. 스크립트 맨 처음에:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

스크립트들(update_readme.py 등)은 filesystem 브랜치를 건드리기 전에 /tmp로 복사돼.

왜? git reset --hard로 filesystem 브랜치로 전환하면 (처음엔 비어있거나 니 디스크가 들어있음), source repo의 .github/scripts 파일들이 workspace에서 사라지거든.

근데 세션 중에 스크립트가 여전히 필요해 (tmate가 다시 시작될 때마다 README를 업데이트하려면). 그래서 git의 손이 닿지 않는 /tmp에 숨겨두고, 나중에 필요할 때 불러오는 거야:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

이걸 생각 안 하면 뒤통수 맞는 버그야: "내 스크립트 왜 사라졌지?". 내가 생각했어.


맞춤형 셸

마지막으로 작은 편의: 세션이 기본 bash가 아니라 설정된 셸을 제공해.

prestart.sh가 커스텀 .bashrc를 복사해:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

이 .bashrc에는 컬러 프롬프트, alias (ll, lla, rm -i), 그리고 특히 똑똑한 게 하나 있어: exit 오버라이드:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

exit (또는 Ctrl+D)를 입력하면, 닫기 전에 tmate 프로세스를 깔끔하게 죽여. 머신에 좀비 tmate 세션이 남는 걸 방지하는 거야.

세션을 죽이지 않고 연결을 끊고 싶으면 tmate-detach 함수도 있어 (나중에 다시 접속하려고). 편의 기능이지만, 얼마나 신경 썼는지 보여줘.


tmate 자동 재시작

작은 편의: 셸에서 exit를 치면, 보통 tmate 세션이 죽고 영원히 연결이 끊겨.

근데 여기서는 tmate가 while true 루프 안에 있어:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

exit을 쳐? 세션이 자동으로 다시 시작돼. 같은 링크로 다시 접속할 수 있어.

멍청하지만 이걸 쓸만하게 만든다.


한 줄로 재접속

연결 끊긴 후에 어떻게 다시 접속할까? 매번 실행 로그를 뒤지지 않고?

tmate의 SSH 주소는 host.conf 파일에 쓰여 있고, 이 파일 자체가 filesystem 브랜치에 commit되어 있어:

printf '%s' "${tmate_ssh#ssh }" > host.conf

이 파일이 git에 있으니까, GitHub API로 한 줄 명령어로 가져올 수 있어:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

이걸 실행하면, repo에서 현재 SSH 주소를 가져와서 바로 접속해. 세션 사이에 주소가 바뀌어도 상관없음.

완전 매끄러워.


전체 플로우

전체 내용을 정리해보자:

1. 워크플로우 실행 (push 또는 수동 버튼)
2. GitHub가 Ubuntu VM을 줌
3. 스크립트가 "filesystem" 브랜치에서 디스크를 복원
4. inotify가 모든 파일 변경을 감시 시작
5. periodic_save가 5초마다 백업 커밋
6. tmate 시작 → SSH/웹 링크 생성
7. 링크가 README + host.conf에 기록됨
8. SSH나 웹 터미널로 접속
9. 하고 싶은 거 다 함 (코딩, 설치, 디버그)
   └── 모든 파일 변경 = 즉시 git으로 autosave
10. 6시간 후, GitHub가 VM을 죽임
11. 하지만 디스크는 "filesystem" 브랜치에 그대로 있음
12. 워크플로우 다시 실행 → 3단계로 돌아감, 모든 게 그대로 있음

VPS. 공짜. 영구 디스크 있음. git과 GitHub Actions만으로.


솔직히 말하면: 한계점

이건 핵이야, 진짜 VPS가 아니야. 그러니까:

  • 실행당 최대 6시간. 정기적으로 워크플로우를 다시 실행해야 해. 무한 uptime 없음.
  • 프로덕션용 아님. 여기 사이트 호스팅하지 마. 탐색, 개발, 디버그, 일회용이지만 복구 가능한 Linux에서 뭐 테스트하기 좋아.
  • GitHub가 다 봄. GitHub의 머신이야. 민감한 거 넣지 마.
  • repo는 private으로 유지해. SSH 셸을 노출하는 거야. Public repo면 잠재적으로 누구나 접속할 수 있어. 나쁜 생각.
  • 이용약관에 걸릴 수 있음. GitHub Actions는 CI/CD용이지, 공짜 VPS용이 아니야. 적당히, 합법적인 용도로, 남용하지 말고 써.

진짜 아킬레스건: git은 큰 파일을 싫어함

더 기술적인 한계가 하나 있는데, 이게 가장 이해해야 할 부분이야.

git은 텍스트용이지, 파일시스템용이 아냐.

영구 디스크는 git 브랜치 안에 살아. 그래서 저장하는 모든 게 git을 통과해. 그리고 git은:

  • 큰 바이너리 파일을 잘 못 다뤄 (2GB Docker 이미지를 git에? 포기해)
  • GitHub에서 파일당 100MB 제한이 있어 (하드 한계, 그 이상은 push 안 됨)
  • repo당 ~5GB 이하를 권장해

그러니까 node_modules 500MB짜리 프로젝트를 npm install 하거나, 무거운 바이너리를 뱉는 빌드를 하면, filesystem으로의 push가 엄청 느리거나 아예 실패할 거야.

git commit --amend가 도움은 돼 (commit 하나, 역사가 불어나지 않음), 하지만 200MB 파일이 통과하지 못하는 건 변함없어.

요약하자면: 코드, 설정, 작은 파일에는 완전 잘 됨. 큰 데이터나 바이너리 아티팩트 저장에는 안 됨. 세션에서 뭘 할지 이걸 명심해야 해.

완전한 시스템 스냅샷이 아님

또 다른 중요한 차이점: filesystem 브랜치는 workspace (repo 폴더)를 저장하지, 시스템 전체가 아니야.

apt install htop을 하면, 바이너리는 /usr/bin/htop에 가는데, 이건 workspace 밖이야. 그래서 저장되지 않아. 다음 실행 때 다시 설치해야 해.

이게 APT 캐시랑 prestart.sh가 있는 이유야: 시스템 환경을 매 시작마다 다시 준비하는 거지, workspace만 유지되니까.

설치한 게 유지되게 하려면 workspace 안에 설치해야 해 (예: 시스템이 아닌 로컬 폴더에 설치). 이걸 감안한 작업 방식이 필요해.


공짜 VPS vs 진짜 VPS: 비교

repo-to-vps 진짜 VPS (5€/월)
가격 0€ ~5-10€/월
Uptime 6시간, 재시작 필요 24/7
디스크 git 브랜치, 작은 파일 진짜 SSD, 수 GB
RAM ~7GB (넉넉해!) 보통 1-2GB
CPU 2-4코어 괜찮음 1-2 vCPU
설정 템플릿 clone 수동 설정
영속성 workspace만 전체 시스템
합법성 이용약관에 걸릴 수 있음 100% 깔끔

웃긴 건 순수 스펙(RAM, CPU)만 보면 GitHub 러너가 5€ VPS보다 종종 더 좋아. 근데 6시간 uptime 제한과 workspace로만 제한된 영속성이 이걸 해커의 장난감으로 만들지, 진짜 서버로 만들지는 않아.

배우고, 테스트하고, 복구 가능한 환경에서 Linux 작업을 빨리 해볼 때? 완벽해. 진지한 걸 호스팅할 때? 진짜 VPS 써.

하지만 원할 때 복원할 수 있는 임시 Linux 환경으로는? 그냥 쩔어.


이 모든 것의 패턴

한 걸음 물러서서 보면, repo-to-vps와 이메일 봇(내 다른 글)은 같은 아이디어에 기반해:

git은 그냥 버전 관리 도구가 아니야. 무료이고, 버전이 있고, API로 접근 가능한 영구 저장소 시스템이야.

Stateless 시스템(GitHub Actions, Worker, 서버리스 함수)이 있고 실행 사이에 상태를 유지하고 싶다면, git을 "디스크"로 쓸 수 있어.

  • 이메일 봇은 lastId를 git 태그에 저장해.
  • repo-to-vps는 파일시스템 전체를 git 브랜치에 저장해.

같은 패턴, 다른 규모. 한쪽은 값, 다른 쪽은 디스크.

그리고 git commit --amend + force-push가 공통 기술이야: 현재 상태를 나타내는 단일 commit을 유지하고, 업데이트할 때마다 덮어써. 역사가 불어나지 않고, 그냥 살아있는 스냅샷일 뿐.

원래 이렇게 쓰라고 만든 건 아니야. 근데 먹히고, 공짜야. 그리고 그게 아름다운 거지.


기억할 3가지:

  1. git 브랜치 = 영구 하드디스크 -- 파일시스템을 전용 브랜치에 저장하고, 시작할 때 복원하면 일회용 머신을 넘어서는 상태를 가질 수 있어.

  2. inotify + git = 실시간 autosave -- inotifywait가 커널 수준에서 변경을 감시하고 즉시 git으로 push해. git commit --amend로 깔끔한 단일 commit 유지.

  3. tmate가 러너를 VPS로 바꾼다 -- GitHub Actions 머신에서 실시간 SSH, 자동 재시작, GitHub API로 한 줄 재접속.

git을 하드디스크처럼, 두 번째 에피소드. 결국엔 모든 걸 git 브랜치에 저장하게 될지도 xD

Repo to VPS: GitHub Actions'ı ücretsiz kalıcı VPS'ye dönüştürün

Bir GitHub Actions runner'ı git'i kalıcı depolama olarak kullanarak sürekli açık bir VPS'ye dönüştürme -- tmate, inotify ve commit --amend.

GitHub sana 6 saatliğine ücretsiz VPS veriyor. Kalıcı hale getirmenin yolunu buldum.

GitHub Actions sana ücretsiz Linux makineleri veriyor.

Yani, gerçek Ubuntu sunucuları. 2 çekirdek, 7 GB RAM, 14 GB disk. Bedava. Run başına 6 saat.

Tek "sorun": run sonunda her şey siliniyor. Makine tek kullanımlık. Bir şeyler kurarsın, kod yazarsın, ayar çekersin... ve poof, her şey gider. Sanki hiçbir şey yapmamışsın gibi.

Ta ki.

Ta ki git'i hard disk olarak kullanana kadar.

Ve işte o an, aniden, run'lar arasında hayatta kalan kalıcı bir diske sahip ücretsiz bir VPS'in olur. Yeniden bağlanırsın, her şey hâlâ oradadır. Kaldığın yerden devam edersin.

Tamamen kırık bu. Anlatayım xD


Arka plan: GitHub Actions runner'ları

Bir GitHub Actions workflow'u başlattığında, GitHub sana bir VM verir.

Kodunu build etmen, testlerini çalıştırman, deploy etmen için yapılmıştır. Workflow çalışır, işini yapar ve makine yok edilir.

Ama bu VM ile başka şeyler yapmanı engelleyen hiçbir şey yok. SSH shell açıp sunucu gibi kullanmak.

Olay şu ki, bu makineler statesiz ve geçici:

  • Geçici: run başına maksimum 6 saat (timeout-minutes: 360, GitHub'ın tavanı)
  • Statesiz: her şey sonunda silinir

Yani bunu kullanılabilir bir VPS yapmak için iki sorunu çözmek gerek:

  1. Gerçek zamanlı olarak nasıl bağlanılır?
  2. İki run arasında disk nasıl korunur?

İşte burada işler hack'e dönüşüyor.


Sorun 1: tmate ile canlı SSH

tmate, paylaşılabilir bir SSH oturumu oluşturan bir tmux fork'u.

Bir makinede çalıştırırsın, sana iki link üretir:

Bu linklerden biriyle bağlanırsın ve boom, makinede bir shell'desin. Gerçek zamanlı.

Workflow tmate'i şöyle başlatır:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Ve bu linkler bir Python script'iyle direkt repo README'ine yazılır. Reponu açarsın, bağlantı linkini görürsün, tıklarsın. İşte VPS'indesin.

İlk sorun çözüldü. Ama asıl çılgınlık ikincisi.


Sorun 2: git hard disk olarak

İşte manyaklık burada.

Makine her run'da siliniyor. O yüzden dosya sistemini filesystem adında özel bir git dalında saklıyoruz.

Başlangıçta, script durumu bu daldan geri yükler:

filesystem_branch="filesystem"
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

filesystem dalı SENİN hard disk'in. Dosyaların, kurulumların, ayarların -- hepsi onun içinde.

Görüyor musun olayı? Makine tek kullanımlık ama disk git'te yaşıyor. Workflow'u yeniden başlatırsın, disk geri yüklenir, kaldığın yerden aynen devam edersin.

Hibernate modu olan bir VPS gibi. Tek farkı hibernasyonun bir git repo'su olması xD

İlk başlatma: boş diski oluşturmak

İlk run'da filesystem dalı henüz yoktur. Oluşturulması gerekir. Ve bu basit bir işlem değil:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan anahtar nokta. Yetim (orphan) dal, hiçbir geçmişi olmayan bir daldır -- sanki boş bir repodan başlıyormuşsun gibi.

Neden yetim? Çünkü kalıcı diskinin tüm kaynak kod geçmişini taşımasını İSTEMEZsin. Disk ayrı bir şey, kendi hayatı olan. Tertemiz başlar.

Ve baştaki git ls-remote --exit-code sadece temiz bir kontrol: "bu dal remote'da zaten var mı?". Varsa dokunma. Yoksa oluştur. Idempotent, sevdiğimiz gibi.

Seçici git clean: önbellekleri korumak

Şu satırda durup bakmak lazım:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx, git tarafından takip edilmeyen HER ŞEYİ siler. Normalde vahşidir -- workspace'i kökünden temizler.

Ama -e (exclude) bazı şeyleri korur:

  • .apt-cache → APT paket önbelleği (buna geri döneceğiz, akıllıca)
  • .cache → genel önbellek
  • host.conf → oturumun SSH adresi
  • tmate.sock → mevcut tmate oturumunun soketi

Bu dosyaları temizlersen, aktif oturumu bozar veya önbelleğini kaybedersin. O yüzden reset sırasında onları atlıyoruz.

İlk bakışta aptal bir detay, ama bunlar olmadan her şey patlar.


Otomatik kaydetme: her şeyi izleyen inotify

Peki dosyalar filesystem dalına nasıl gidiyor?

Cevap: TÜM dosya değişikliklerini izleyen ve otomatik olarak commit/push yapan bir watcher.

Sihirli araç inotifywait (inotify-tools paketinden). Kernel seviyesinde dosya sistemini izler ve bir dosya değiştiğinde tetiklenir.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

inotify flag'lerini inceleyelim, çünkü her biri önemli:

  • -r → recursive, tüm alt klasörleri izler
  • -e modify,create,delete,move → bu 4 olay türüne tepki verir (değişiklik, oluşturma, silme, taşıma)
  • --exclude '...' → bazı dosyaları yok saymak için bir regex

--exclude hayati önemde. Neleri yok saydığına bak:

  • .git → tabii ki, yoksa her commit bir autosave tetikler, o da bir commit tetikler... sonsuz döngü. Felaket.
  • .apt-cache ve .cache → sürekli değişen ve git'i spamlamak istemediğimiz önbellekler
  • host.conf ve tmate.sock → durmadan değişen oturum dosyaları
  • .gitignore, .txt.swp → geçici dosyalar (.swp vim düzenleme dosyaları)

Bu exclude olmadan, autosave kendi değişiklikleriyle döngüye girerdi. Listedeki .git, ayağına sıkmaktan seni koruyan SATIR.

Bir dosyayı değiştirirsin? inotify anında algılar, commit atar, push yapar. Bir saniyeden kısa sürede, değişikliğin filesystem dalında.

Bir şey kurarsın, kod yazarsın, ayar değiştirirsin -- her şey gerçek zamanlı, otomatik, sen hiçbir şey yapmadan kaydedilir.

Gerçek anlamda tüm diskin otomatik yedekleme sistemine sahipsin. Kırık.

Debounce: git'i spamlamamak

Her kayıttan sonraki sleep 1 bir debounce.

Bir editörde dosya kaydettiğinde, genelde peş peşe birden fazla dosya sistemi olayı oluşur (geçici dosya oluşturma, rename, eskisini silme...). Debounce olmadan, tek bir kayıt için 3-4 commit tetiklenirdi.

sleep 1 şunu der: "bir kayıttan sonra bir saniye bekle, patlama dinsin, sonra tekrar dinlemeye başla". Yakın zamandaki değişiklikleri tek bir commit'te toplar. Akıllıca.

Bir de periyodik yedekleme

Her ihtimale karşı, inotify bir şey kaçırırsa diye her 5 saniyede bir de kayıt var:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

Kuşak gibi askı gibi. Disk durumunu kaybetmek istemiyoruz hiç.


Akıllı detay: tek commit

Her dosya değişikliğinde commit atarsan... binlerce commit birikir. Bir saatlik oturumda git geçmişin patlar. Repo kocaman olur. İğrenç.

Çözüm zarif: yeni commit oluşturmak yerine varolanı değiştiriyoruz (amend).

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend şu anlama gelir: "son commit'i bununla değiştir."

Böylece filesystem dalında HER ZAMAN tek bir commit olur. Ne kadar kaydedersen kaydet. Sadece mevcut durumun bir anlık görüntüsü, defalarca force-push edilmiş.

flock bir kilit: iki kaydetme döngüsü olduğu için (inotify + periyodik), ikisinin aynı anda git'i çalıştırıp birbirine girmesini engellemek gerek. Aynı anda tek bir git işlemi.

Temiz.


sync_from_remote: birden çok oturumu yönetmek

İlk başta aklına gelmeyen bir şey: ya aynı anda İKİ run başlatırsan? Ya da bir oturum filesystem dalını değiştirirken başka bir oturum çalışıyorsa?

Script bunu her commit'ten önce sync_from_remote ile halleder:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (sadece fast-forward) önemli: "SADECE düzgün bir şekilde ilerletebiliyorsak merge yap, merge commit'i oluşturmadan" anlamına gelir.

İki dal birbirinden ayrılmışsa (mesela iki oturum farklı şeyleri değiştirmişse), fast-forward sessizce başarısız olur (2>/dev/null || true) ve yerel durum korunur. Mükemmel bir merge sistemi değil, ama tek bir oturumun çalıştığı basit durumda bozulmaları önler.

Açıkçası, aynı repo'da 3 paralel oturum başlatmamalısın. Ama kod yine de patlamamaya çalışıyor. Savunma amaçlı.


APT önbelleği: hızlı kurulum

Workflow'da göze çarpmayan ama iyi düşünülmüş bir detay var:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate ve inotify-tools, APT paketlerini önbelleğe alan bir action ile kuruluyor.

İlk run'da indirir ve kurar. Sonraki run'larda GitHub Actions önbelleğinden geri yüklenir -- daha hızlı, yeniden indirmeye gerek kalmaz.

Az önceki git clean -fdx -e .apt-cache'i hatırladın mı? İşte bağlantılı. .apt-cache klasörü, oturum sırasında kurduğun paketlerin bir şekilde kalıcı olabilmesi için temizlikten korunuyor.

Her şey birbirini tutuyor.


/tmp'ye gizlenen script'ler

Sinsi ama akıllıca bir detay daha. Script'in en başında:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Script'ler (update_readme.py vb.) filesystem dalına dokunmadan ÖNCE /tmp'ye kopyalanır.

Neden? Çünkü git reset --hard ile filesystem dalına geçtiğinde (başlangıçta boş veya diskin neyse o), kaynak repodaki .github/scripts dosyaları workspace'ten kaybolur.

Ama README'i güncellemek için hâlâ gereklidir. O yüzden /tmp'ye saklar:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Düşünmezsen script'in kaybolur. Ben düşündüm.


Özel yapım shell

Son bir konfor: oturum sana çıplak bir bash değil, yapılandırılmış bir shell veriyor.

prestart.sh özel bir .bashrc kopyalar:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

Ve bu .bashrc renkli bir prompt, alias'lar (ll, lla, rm -i) ve işte akıllı bir şey içerir: exit override'ı:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

exit (veya Ctrl+D) yazdığında, kapatmadan önce tmate process'lerini temizce öldürür. Böylece makinede zombi tmate oturumları kalmaz.

Bir de tmate-detach fonksiyonu var, oturumu öldürmeden bağlantıyı kesmek istersen diye (sonra tekrar bağlanmak için). Küçük bir konfor detayı, ama özen seviyesini gösteriyor.


Kendi kendine yeniden başlayan tmate

Küçük bir konfor: shell'de exit yazdığında, normalde tmate oturumu ölür ve bir daha bağlanamazsın.

Ama burada tmate bir while true döngüsü içinde:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

exit yaparsın? Oturum kendi kendine yeniden başlar. Aynı linkle tekrar bağlanabilirsin. Düşüşten sonra bile istikrarlı bağlantı.

Aptalca ama kullanışlı hale getiriyor.


Tek komutla yeniden bağlanma

Düşüşten sonra, her seferinde run log'larını karıştırmadan nasıl yeniden bağlanırsın?

tmate'in SSH adresi host.conf dosyasına yazılır ve filesystem dalında commit'lenir:

printf '%s' "${tmate_ssh#ssh }" > host.conf

Ve bu dosya git'te olduğu için, GitHub API'si ile tek bir komutla alabilirsin:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Bunu çalıştırırsın, repo'daki güncel SSH adresini alır ve direkt bağlanır. İki oturum arasında adres değişmiş olsa bile.

Tamam.


Tam akış

Özetleyelim:

1. Workflow'u tetiklersin (push veya manuel buton)
2. GitHub sana bir Ubuntu VM verir
3. Script diski "filesystem" dalından geri yükler
4. inotify tüm değişiklikleri izlemeye başlar
5. periodic_save her 5 saniyede bir yedek commit atar
6. tmate başlar → SSH/web linklerini oluşturur
7. Linkler README + host.conf'a yazılır
8. ssh veya web terminali ile bağlanırsın
9. Ne istersen yaparsın (kod yaz, kur, debug et...)
   └── her dosya değişikliği = git'e anlık otomatik kayıt
10. 6 saat sonra GitHub VM'i öldürür
11. Ama diskin "filesystem" dalında sapasağlamdır
12. Workflow'u yeniden başlatırsın → 3. adıma dön, her şey hâlâ orada

Bir VPS. Ücretsiz. Kalıcı diskli. Sadece git ve GitHub Actions ile.


Tamam, dürüst olalım: sınırlamalar

Bu bir hack, gerçek VPS değil. Yani:

  • Run başına maksimum 6 saat. Workflow'u düzenli olarak yeniden başlatman gerekir. Sonsuz uptime yok.
  • Prod için değil. Siteni burada barındırmayacaksın. Keşfetmek, geliştirmek, debug yapmak, tek kullanımlık ama kurtarılabilir bir Linux'ta bir şey test etmek için.
  • GitHub her şeyi görür. Onların makineleri. Hassas bir şey koyma.
  • Repoyu gizli tut. Bir SSH shell'i açıyorsun. Herkese açık repo = potansiyel olarak herkes bağlanabilir. Kötü fikir.
  • Kullanım koşullarının sınırında. GitHub Actions CI/CD için yapılmıştır, ücretsiz VPS için değil. Ölçülü, meşru, suistimal etmeden kullan.

Gerçek zayıf nokta: git büyük dosyalardan nefret eder

Daha teknik bir sınırlama var ve anlaşılması en önemlisi.

Git metin için yapılmıştır, dosya sistemi için değil.

Kalıcı disk bir git dalında yaşar. Yani kaydettiğin her şey git'ten geçer. Ve git:

  • büyük binary dosyaları kötü yönetir (2 GB'lık bir Docker imajı git'te? unut gitsin)
  • GitHub'da dosya başına 100 MB sınırı vardır (hard limit, üstü push olmaz)
  • repo başına ~5 GB'ın altında kalmanı önerir

Yani 500 MB node_modules'lu bir projede npm install yaparsan veya ağır binary'ler çıkaran bir şey build edersen, filesystem'e push ya deli gibi yavaşlar ya da tamamen başarısız olur.

git commit --amend yardımcı olur (tek commit, şişen geçmiş yok), ama 200 MB'lık bir dosyanın asla geçmeyeceği gerçeğini değiştirmez.

Kısacası: kod, ayar dosyaları, küçük dosyalar için harika çalışır. Büyük verileri veya binary artifaktları depolamak için çalışmaz. Oturumunda ne yaptığını buna göre ayarlamalısın.

Tam sistem anlık görüntüsü değil

Bir diğer önemli nüans: filesystem dalı workspace'i (repo klasörünü) kaydeder, tüm sistemi değil.

apt install htop yaparsan, binary /usr/bin/htop'a gider, ki bu workspace DIŞINDADIR. Yani kaydedilmez. Sonraki run'da yeniden kurman gerekir.

İşte bu yüzden APT önbelleği ve prestart.sh var: her başlangıçta sistem ortamını yeniden hazırlamak için, çünkü sadece workspace kalıcı.

Kurulumlarının hayatta kalmasını istiyorsan, onları workspace'in içine koymalısın (mesela sistem yerine yerel bir klasöre kurmak). Alışman gereken bir jimnastik.


Ücretsiz VPS vs gerçek VPS: karşılaştırma

repo-to-vps Gerçek VPS (5€/ay)
Fiyat 0€ ~5-10€/ay
Çalışma süresi 6 saat, yeniden başlatmak gerek 7/24
Disk git dalı, küçük dosyalar gerçek SSD, birkaç GB
RAM ~7 GB (cömert!) genelde 1-2 GB
CPU 2-4 iyi çekirdek 1-2 vCPU
Kurulum template'i clone'la manuel yapılandırma
Kalıcılık sadece workspace tam sistem
Meşruiyet kullanım koşullarının sınırında %100 clean

Komik olan şu ki ham özelliklerde (RAM, CPU) GitHub runner genelde 5€'luk bir VPS'ten DAHA İYİ. Ama 6 saatlik çalışma süresi ve workspace ile sınırlı kalıcılık, bunu gerçek bir sunucu değil, bir hacker oyuncağı yapıyor.

Öğrenmek, test etmek, kurtarılabilir bir ortamda hızlıca bir Linux şeyini debug etmek için? Mükemmel. Ciddi bir şey barındırmak için? Gerçek bir VPS al.

Ama dilediğin gibi geri yükleyebileceğin geçici bir Linux ortamı için? Harika.


Tüm bunların ardındaki desen

Geriye çekilip bakarsan, repo-to-vps ve email bot'u (diğer yazım) aynı fikre dayanıyor:

Git sadece bir versiyon yöneticisi değil. API'si ile erişilebilen, ücretsiz, versiyonlu, kalıcı bir depolama sistemidir.

Statesiz bir sistemin (GitHub Actions, bir Worker, serverless fonksiyon) olduğunda ve iki çalıştırma arasında bir durum tutmak istediğinde, git "disk" görevi görebilir.

  • Email bot'u bir lastId'yi bir git tag'inde saklar.
  • repo-to-vps bütün bir dosya sistemini bir git dalında saklar.

Aynı desen, iki farklı ölçek. Bir tarafta bir değer, diğer tarafta bir disk.

Ve git commit --amend + force-push ortak teknik: her güncellemede üzerine yazılan, mevcut durumu temsil eden tek bir commit tutuyorsun.

Bunun için yapılmadı. Ama çalışıyor. Ve ücretsiz.


Unutulmaması gereken 3 şey:

  1. Bir git dalı = kalıcı bir hard disk -- Dosya sistemini özel bir dalda sakla, başlangıçta geri yükle, tek kullanımlık makinelerde hayatta kalan bir durumun olsun.

  2. inotify + git = gerçek zamanlı otomatik kayıt -- inotifywait kernel seviyesinde değişiklikleri izler ve anında git'e push eder. git commit --amend ile temiz bir tek commit korunur.

  3. tmate runner'ı VPS'e dönüştürür -- GitHub Actions makinesinde canlı SSH, otomatik yeniden başlatma ve GitHub API'si ile tek komutta yeniden bağlanma.

Git hard disk olarak, ikinci bölüm. Sanırım sonunda her şeyi git dallarında saklayacağım xD

Repo to VPS: trasforma GitHub Actions in un VPS gratuito con storage persistente

Come trasformare un runner GitHub Actions in un VPS sempre attivo usando git come storage persistente -- tmate, inotify e commit --amend.

GitHub ti regala un VPS gratis per 6h. Ho trovato come renderlo permanente.

GitHub Actions ti dà macchine Linux gratuite.

Tipo, veri server Ubuntu. 2 core, 7 GB di RAM, 14 GB di disco. Gratis. Per 6h a run.

L'unico "problema": alla fine del run, tutto viene cancellato. La macchina è usa e getta. Installi robe, codi, configuri... e puf, alla fine tutto scompare. Come se non avessi fatto niente.

A meno che.

A meno che tu non usi git come disco rigido.

E allora, di colpo, hai un VPS gratis con un disco persistente che sopravvive ai run. Ti riconnetti, è tutto ancora lì. Riprendi da dove eri rimasto.

È completamente rotto. Lascia che ti spieghi xD


Il contesto: i runner GitHub Actions

Quando lanci un workflow GitHub Actions, GitHub ti fila una VM.

È fatta per buildare il tuo codice, lanciare i test, fare deploy. Il workflow gira, fa il suo lavoro, e la macchina viene distrutta.

Ma niente ti impedisce di fare altro con questa VM. Aprire una shell SSH e usarla come server.

Il fatto è che queste macchine sono stateless e temporanee:

  • Temporanee: 6h max per run (timeout-minutes: 360, il limite di GitHub)
  • Stateless: tutto cancellato alla fine

Quindi per farne un VPS utilizzabile, bisogna risolvere due problemi:

  1. Come connettersi sopra in tempo reale?
  2. Come mantenere il disco tra un run e l'altro?

È qui che diventa un hack.


Problema 1: SSH live con tmate

tmate è un fork di tmux che crea una sessione SSH condivisibile.

Lo lanci su una macchina, ti genera due link:

Ti connetti con uno di questi link, e boom, sei in una shell sulla macchina. In tempo reale.

Il workflow quindi lancia tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

E questi link vengono scritti direttamente nel README del repo da uno script Python. Apri il tuo repo, vedi il link di connessione, clicchi. Eccoti nel tuo VPS.

Primo problema risolto. Ma è il secondo che è veramente pazzesco.


Problema 2: git come disco rigido

Ecco il trip mentale.

La macchina viene cancellata a ogni run. Quindi salviamo il filesystem in un branch git dedicato, chiamato filesystem.

All'avvio, lo script ripristina lo stato da quel branch:

filesystem_branch="filesystem"
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

Il branch filesystem È il tuo disco rigido. I tuoi file, le tue installazioni, le tue configurazioni -- tutto è lì dentro.

Capisci il trucco? La macchina è usa e getta, ma il disco vive in git. Rilancia il workflow, il disco viene ripristinato, riprendi esattamente da dove eri.

È come un VPS che va in ibernazione. Solo che l'ibernazione è un repo git xD

Primo avvio: creare il disco vuoto

Al primissimo run, il branch filesystem non esiste ancora. Bisogna crearlo. E non è banale:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

Il git checkout --orphan è la chiave. Un branch orfano è un branch senza nessuno storico -- come se ripartissi da un repo vuoto.

Perché orfano? Perché NON vuoi che il tuo disco persistente si porti dietro tutto lo storico del tuo codice sorgente. Il disco è una cosa a parte, con una vita propria. Comincia vergine.

E il git ls-remote --exit-code all'inizio, è solo un check pulito: "il branch esiste già sul remote?". Se sì, non si tocca nulla. Se no, lo si crea. Idempotente, come piace a noi.

Il git clean selettivo: proteggere le cache

Questa riga merita una sosta:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx rimuove TUTTO ciò che non è tracciato da git. Normalmente è violento -- pulisce il workspace a fondo.

Ma i -e (exclude) proteggono alcune cose:

  • .apt-cache → la cache dei pacchetti APT (ci torniamo, è furbo)
  • .cache → cache generica
  • host.conf → l'indirizzo SSH della sessione
  • tmate.sock → il socket della sessione tmate in corso

Se pulissi questi file, romperesti la sessione attiva o perderesti la cache. Quindi li risparmiamo durante il reset.

Un dettaglio stupido a prima vista, ma senza questo tutto esplode.


L'autosave: inotify che monitora tutto

Ok, ma come finiscono i file nel branch filesystem?

Risposta: un watcher che monitora TUTTE le modifiche ai file e fa commit/push automaticamente.

Lo strumento magico è inotifywait (dal pacchetto `inotify-tools'). Monitora il filesystem a livello kernel e si attiva appena un file cambia.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

Analizziamo i flag di inotify, perché ognuno conta:

  • -r → ricorsivo, monitora tutte le sottocartelle
  • -e modify,create,delete,move → reagisce a questi 4 tipi di eventi (modifica, creazione, cancellazione, spostamento)
  • --exclude '...' → una regex per ignorare certi file

Il --exclude è cruciale. Guarda cosa ignora:

  • .git → ovviamente, altrimenti ogni commit scatenerebbe un autosave che scatenerebbe un commit... ciclo infinito. Catastrofe.
  • .apt-cache e .cache → le cache, che cambiano in continuazione e non vogliamo spammare in git
  • host.conf e tmate.sock → i file di sessione, che cambiano senza sosta
  • .gitignore, .txt.swp → i file temporanei (i .swp sono i file di modifica di vim)

Senza questo exclude, ti ritroveresti con un autosave che si attiva a ripetizione sulle proprie modifiche. Il .git nella lista, è LA riga che ti impedisce di spararti sul piede.

Modifichi un file? inotify lo rileva istantaneamente, fa commit, fa push. In meno di un secondo, la tua modifica è nel branch filesystem.

Installi qualcosa, scrivi codice, modifichi una configurazione -- tutto viene salvato in tempo reale, automaticamente, senza che tu faccia nulla.

Hai letteralmente un sistema di backup automatico dell'intero disco. Rotto.

Il debounce: non spammare git

Il sleep 1 dopo ogni salvataggio è un debounce.

Quando salvi un file in un editor, spesso genera diversi eventi filesystem in raffica (creazione di un file temp, rename, cancellazione del vecchio...). Senza debounce, scatterebbero 3-4 commit per un singolo salvataggio.

Il sleep 1 dice: "aspetta un secondo dopo un salvataggio, il tempo che la raffica si calmi, prima di riascoltare". Raggruppa le modifiche vicine in un unico commit. Furbo.

E un salvataggio periodico in più

Nel caso inotify perdesse qualcosa, c'è anche un salvataggio ogni 5 secondi:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

Cintura E bretelle. Non vogliamo assolutamente perdere lo stato del disco.


Il dettaglio furbo: un singolo commit

Se fai commit a ogni modifica di file, accumuli... migliaia di commit. In un'ora di sessione, la tua storia git esplode. Il repo diventa enorme. È schifoso.

La soluzione è elegante: si modifica il commit esistente invece di crearne uno nuovo.

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend significa: "sostituisci l'ultimo commit con questo".

Così il branch filesystem ha SEMPRE un solo commit. Non importa quante volte salvi. È solo uno snapshot dello stato attuale, force-pushato ancora e ancora.

Il flock è un lucchetto: dato che ci sono due loop di salvataggio (inotify + periodico), bisogna evitare che lanciano git contemporaneamente e si pestino i piedi. Un solo processo git alla volta.

Pulito.


Il sync_from_remote: gestire più sessioni

Ecco, una cosa a cui non pensi all'inizio: e se lanci DUE run contemporaneamente? O se una sessione modifica il branch filesystem mentre un'altra è in esecuzione?

Lo script gestisce la cosa con un sync_from_remote prima di ogni commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

Il --ff-only (fast-forward only) è importante: significa "fare merge SOLO se si può andare avanti pulitamente, senza creare un commit di merge".

Se i due branch hanno divergito (tipo, due sessioni hanno modificato cose diverse), il fast-forward fallisce silenziosamente (2>/dev/null || true) e si mantiene lo stato locale. Non è un sistema di merge perfetto, ma evita corruzioni nel caso semplice in cui una sola sessione è attiva.

Onestamente, non bisogna lanciare 3 sessioni in parallelo sullo stesso repo. Ma il codice cerca comunque di non esplodere se succede. È difensivo.


La cache APT: installare velocemente

C'è un dettaglio nel workflow che non sembra ma è ben pensato:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate e inotify-tools vengono installati tramite un'azione che fa cache dei pacchetti APT.

Al primo run, scarica e installa. Ai run successivi, viene ripristinato dalla cache di GitHub Actions -- più veloce, senza bisogno di riscaricare.

E ti ricordi il git clean -fdx -e .apt-cache di prima? È collegato. La cartella .apt-cache è protetta dalla pulizia proprio perché i pacchetti che installi durante la sessione possano persistere un minimo.

Tutto si tiene.


Gli script nascosti in /tmp

Ancora un dettaglio subdolo ma furbo. All'inizio dello script:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Gli script (update_readme.py, ecc.) vengono copiati in /tmp PRIMA di toccare il branch filesystem.

Perché? Perché quando fai git reset --hard verso il branch filesystem (che è vuoto all'inizio, o contiene il tuo disco), i file .github/scripts del repo originale scompaiono dal workspace.

Ma servono per aggiornare il README. Quindi li nasconde in /tmp:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Se non ci pensi, lo script sparisce. Io ci ho pensato.


La shell personalizzata

Piccolo comfort finale: la sessione ti dà una shell configurata, non un bash nudo.

Il prestart.sh copia un .bashrc custom:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

E questo .bashrc contiene un prompt colorato, alias (ll, lla, rm -i), e soprattutto una cosa furba: un override di exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

Quando scrivi exit (o Ctrl+D), uccide pulitamente i processi tmate prima di chiudere. Evita di lasciare sessioni tmate zombie in giro sulla macchina.

C'è anche una funzione tmate-detach se vuoi disconnetterti SENZA uccidere la sessione (per riconnetterti dopo). Dettaglio di comfort, ma mostra il livello di cura.


Il tmate che si riavvia da solo

Piccolo comfort: se scrivi exit nella tua shell, normalmente la sessione tmate muore e vieni disconnesso per sempre.

Tranne che qui, tmate è in un loop while true:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

Fai exit? La sessione si riavvia da sola. Puoi riconnetterti con lo stesso link. Riconnessione stabile, anche dopo una disconnessione.

È stupido, ma lo rende utilizzabile.


La riconnessione in un comando

Come ti riconnetti dopo una disconnessione, senza andare a cercare nei log del run ogni volta?

L'indirizzo SSH di tmate è scritto in un file host.conf, che a sua volta è committato nel branch filesystem:

printf '%s' "${tmate_ssh#ssh }" > host.conf

E poiché questo file è in git, puoi recuperarlo tramite l'API GitHub con un solo comando:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Lanci questo, va a prendere l'indirizzo SSH attuale nel repo, e ti connette direttamente. Anche se l'indirizzo è cambiato tra due sessioni.

Fatto.


Il flusso completo

Ricapitoliamo:

1. Attivi il workflow (push o pulsante manuale)
2. GitHub ti fila una VM Ubuntu
3. Lo script ripristina il disco dal branch "filesystem"
4. inotify comincia a monitorare tutte le modifiche
5. periodic_save fa commit ogni 5s come backup
6. tmate si avvia → genera i link SSH/web
7. I link vengono scritti nel README + host.conf
8. Ti connetti con ssh o il terminale web
9. Fai quello che vuoi (codare, installare, debug...)
   └── ogni modifica ai file = autosave istantaneo su git
10. 6h dopo, GitHub uccide la VM
11. Ma il tuo disco è intatto nel branch "filesystem"
12. Rilancia il workflow → torna al passo 3, tutto è ancora lì

Un VPS. Gratis. Con disco persistente. Solo con git e GitHub Actions.


Ok, bisogna essere onesti: i limiti

È un hack, non un vero VPS. Quindi:

  • 6h max per run. Bisogna rilanciare il workflow regolarmente. Niente uptime infinito.
  • Non per produzione. Non ospiterai il tuo sito lì sopra. È per esplorare, sviluppare, debug, testare qualcosa in un Linux usa e getta ma recuperabile.
  • GitHub vede tutto. Sono le loro macchine. Non metterci nulla di sensibile.
  • Tieni il repo privato. Esponi una shell SSH. Un repo pubblico = chiunque può potenzialmente connettersi. Pessima idea.
  • È al limite dei termini di servizio. GitHub Actions è fatto per CI/CD, non per VPS gratis. Quindi da usare con parsimonia, per cose legittime, senza abusare.

Il vero tallone d'Achille: git odia i file grandi

C'è un limite più tecnico, ed è il più importante da capire.

Git è fatto per testo, non per un filesystem.

Il disco persistente vive in un branch git. Quindi tutto ciò che salvi passa attraverso git. E git:

  • gestisce male i file binari grandi (un'immagine Docker da 2 GB in git? scordatelo)
  • ha un limite di 100 MB per file su GitHub (hard limit, non si pusha oltre)
  • raccomanda di stare sotto ~5 GB per repo

Quindi se fai npm install di un progetto con 500 MB di node_modules, o se buildi qualcosa che produce binari pesanti, il push verso filesystem o farà una fatica bestiale, o fallirà del tutto.

Il git commit --amend aiuta (un solo commit, nessuna storia che si gonfia), ma non cambia il fatto che un file da 200 MB non passerà mai.

In pratica: funziona benissimo per codice, configurazioni, file piccoli. Non funziona per salvare dati grossi o artefatti binari. Bisogna tenerlo a mente su cosa fai nella tua sessione.

Non è uno snapshot completo del sistema

Altra sfumatura importante: il branch filesystem salva il workspace (la cartella del repo), non tutto il sistema.

Se fai apt install htop, il binario finisce in /usr/bin/htop, che è FUORI dal workspace. Quindi NON verrà salvato. Al prossimo run, bisogna reinstallarlo.

È per questo che abbiamo la cache APT e prestart.sh: per ripreparare l'ambiente sistema a ogni avvio, visto che solo il workspace persiste.

Se vuoi che le tue installazioni sopravvivano, devi metterle nel workspace (tipo, installare in una cartella locale invece che di sistema). È una ginnastica da imparare.


VPS gratis vs vero VPS: il confronto

repo-to-vps Vero VPS (5€/mese)
Prezzo 0€ ~5-10€/mese
Uptime 6h, da riavviare 24/7
Disco branch git, file piccoli vero SSD, diversi GB
RAM ~7 GB (generoso!) 1-2 GB spesso
CPU 2-4 core decenti 1-2 vCPU
Setup clona un template configurazione manuale
Persistenza solo workspace sistema completo
Legittimità al limite dei ToS 100% clean

La cosa divertente è che a livello di specifiche pure (RAM, CPU), il runner GitHub è spesso MIGLIORE di un VPS da 5€. Ma l'uptime di 6h e la persistenza limitata al workspace sono ciò che lo rendono un giocattolo da hacker, non un vero server.

Per imparare, testare, fare debug rapido di qualcosa in Linux in un ambiente recuperabile? Perfetto. Per ospitare qualunque cosa di serio? Prendi un vero VPS.

Ma per un ambiente Linux temporaneo che puoi ripristinare a piacimento? Semplicemente geniale.


Il pattern dietro tutto questo

Se fai un passo indietro, repo-to-vps e il bot email (il mio altro articolo) si basano sulla stessa idea:

Git non è solo un sistema di versionamento. È un sistema di storage persistente, gratuito, versionato, accessibile tramite API.

Appena hai un sistema stateless (GitHub Actions, un Worker, una funzione serverless) e vuoi mantenere uno stato tra due esecuzioni, git può fungere da "disco".

  • Il bot email salva un lastId in un tag git.
  • repo-to-vps salva un intero filesystem in un branch git.

Stesso pattern, due scale. Un valore da una parte, un disco dall'altra.

E il git commit --amend + force-push è la tecnica comune: mantieni un singolo commit che rappresenta lo stato attuale, sovrascritto a ogni aggiornamento.

Non è stato progettato per questo. Ma funziona. Ed è gratis.


Le 3 cose da ricordare:

  1. Un branch git = un disco rigido persistente -- Salva il tuo filesystem in un branch dedicato, ripristina all'avvio, e hai uno stato che sopravvive alle macchine usa e getta.

  2. inotify + git = autosave in tempo reale -- inotifywait monitora le modifiche a livello kernel e fa push su git istantaneamente. Con git commit --amend per mantenere un singolo commit pulito.

  3. tmate trasforma un runner in VPS -- SSH live su una macchina GitHub Actions, con riavvio automatico e riconnessione in un comando tramite l'API GitHub.

Git come disco rigido, secondo episodio. Credo che finirò per salvare tutto in branch git xD

Repo to VPS: GitHub Actions in einen kostenlosen VPS mit persistentem Speicher verwandeln

Wie man einen GitHub Actions Runner mit git als persistentem Speicher in einen Dauer-VPS verwandelt -- tmate, inotify und commit --amend.

GitHub gibt dir 'nen kostenlosen VPS für 6h. Ich hab rausgefunden, wie du ihn permanent machst.

GitHub Actions gibt dir kostenlose Linux-Maschinen.

So richtig. Echte Ubuntu-Server. 2 Kerne, 7 GB RAM, 14 GB Platte. Kostenlos. Für 6h pro Run.

Das einzige "Problem": Am Ende des Runs wird alles gelöscht. Die Maschine ist wegwerfbar. Du installierst Zeug, codest, konfigurierst... und zack, am Ende ist alles weg. Als ob du nichts gemacht hättest.

Außer wenn.

Außer wenn du Git als Festplatte benutzt.

Und auf einmal hast du 'nen kostenlosen VPS mit 'ner persistenten Platte, die Runs überlebt. Du verbindest dich neu, alles ist noch da. Du machst weiter, wo du aufgehört hast.

Das ist komplett kaputt. Lass mich erklären xD


Der Kontext: GitHub Actions Runner

Wenn du 'nen GitHub Actions Workflow startest, gibt dir GitHub 'ne VM.

Die ist dazu da, deinen Code zu builden, Tests zu starten, zu deployen. Der Workflow läuft, macht seinen Job, und die Maschine wird zerstört.

Aber nichts hält dich davon ab, was anderes mit dieser VM zu machen. 'Ne SSH-Shell öffnen und als Server benutzen.

Der Punkt ist, diese Maschinen sind zustandslos und temporär:

  • Temporär: max 6h pro Run (timeout-minutes: 360, das GitHub-Limit)
  • Zustandslos: alles wird am Ende gelöscht

Also um daraus 'nen brauchbaren VPS zu machen, musst du zwei Probleme lösen:

  1. Wie verbinde ich mich in Echtzeit damit?
  2. Wie behalte ich die Platte zwischen zwei Runs?

Und hier wird's zu 'nem Hack.


Problem 1: Live-SSH mit tmate

tmate ist ein Fork von tmux, der 'ne teilbare SSH-Session erstellt.

Du startest es auf 'ner Maschine, es generiert zwei Links:

Du verbindest dich mit einem dieser Links, und boom, du bist in 'ner Shell auf der Maschine. In Echtzeit.

Der Workflow startet also tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Und diese Links werden von 'nem Python-Script direkt in die README des Repos geschrieben. Du öffnest dein Repo, siehst den Verbindungslink, klickst drauf. Schon bist du in deinem VPS.

Erstes Problem gelöst. Aber das zweite ist echt verrückt.


Problem 2: Git als Festplatte

Hier kommt das kranke Ding.

Die Maschine wird bei jedem Run gelöscht. Also speichern wir das Dateisystem in 'nem dedizierten Git-Branch, genannt filesystem.

Beim Start stellt das Script den Zustand aus diesem Branch wieder her:

filesystem_branch="filesystem"
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

Der Branch filesystem IST deine Festplatte. Deine Dateien, deine Installationen, deine Konfigs -- alles ist drin.

Checkst du das? Die Maschine ist wegwerfbar, aber die Platte lebt in Git. Du startest den Workflow neu, die Platte wird wiederhergestellt, du machst genau da weiter, wo du warst.

Es ist wie ein VPS, der in den Ruhezustand geht. Nur dass der Ruhezustand ein Git-Repo ist xD

Erster Start: Leere Platte erstellen

Beim allerersten Run existiert der Branch filesystem noch nicht. Muss erstellt werden. Und das ist nicht trivial:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

Das git checkout --orphan ist der Schlüssel. Ein Orphan-Branch ist ein Branch ganz ohne History -- als ob du mit 'nem leeren Repo von vorne anfängst.

Warum orphan? Weil du NICHT willst, dass deine persistente Platte die ganze History deines Quellcodes mitschleppt. Die Platte ist 'ne eigene Sache, die ihr eigenes Leben führt. Sie startet leer.

Und das git ls-remote --exit-code am Anfang ist einfach 'ner sauberer Check: "gibt es den Branch schon auf dem Remote?". Wenn ja, nichts tun. Wenn nein, wird er erstellt. Idempotent, wie wir es mögen.

Das selektive Git Clean: Caches schützen

Diese Zeile verdient 'nen genaueren Blick:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx löscht ALLES, was nicht von Git getrackt wird. Normalerweise ist das heftig -- es putzt das Workspace komplett durch.

Aber die -e (exclude) schützen bestimmte Dinge:

  • .apt-cache → der Cache der APT-Pakete (dazu kommen wir gleich, das ist clever)
  • .cache → generischer Cache
  • host.conf → die SSH-Adresse der Session
  • tmate.sock → der Socket der aktuellen tmate-Session

Wenn du diese Dateien putzen würdest, würdest du die aktive Session killen oder deinen Cache verlieren. Also werden sie beim Reset verschont.

'n blödes Detail auf den ersten Blick, aber ohne das geht alles kaputt.


Der Autosave: Inotify überwacht alles

Okay, aber wie landen die Dateien im Branch filesystem?

Antwort: 'n Watcher, der ALLE Dateiänderungen überwacht und automatisch committed/pusht.

Das magische Tool ist inotifywait (aus dem Paket inotify-tools). Es überwacht das Dateisystem auf Kernel-Ebene und feuert los, sobald sich 'ne Datei ändert.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

Lass uns die inotify-Flags aufdröseln, weil jedes zählt:

  • -r → rekursiv, überwacht alle Unterordner
  • -e modify,create,delete,move → reagiert auf diese 4 Ereignistypen (Änderung, Erstellung, Löschung, Verschiebung)
  • --exclude '...' → 'ne Regex, um bestimmte Dateien zu ignorieren

Das --exclude ist entscheidend. Schau, was es ignoriert:

  • .git → logisch, sonst würde jeder Commit 'nen Autosave auslösen, der 'nen Commit auslöst... Endlosschleife. Katastrophe.
  • .apt-cache und .cache → die Caches, die sich ständig ändern und die man nicht in Git spammen will
  • host.conf und tmate.sock → die Session-Dateien, die sich dauernd ändern
  • .gitignore, .txt.swp → temporäre Dateien (die .swp sind vim-Editierdateien)

Ohne diesen Exclude würdest du 'nen Autosave bekommen, der sich bei seinen eigenen Änderungen immer wieder selbst triggert. Das .git in der Liste ist DIE Zeile, die verhindert, dass du dich selbst ins Knie schießt.

Du änderst 'ne Datei? Inotify erkennt es sofort, es wird committed, gepusht. In unter 'ner Sekunde ist deine Änderung im Branch filesystem.

Du installierst was, schreibst Code, änderst 'ne Config -- alles wird in Echtzeit gespeichert, automatisch, ohne dass du irgendwas tun musst.

Du hast buchstäblich 'n automatisches Backup-System für die ganze Platte. Kaputt.

Das Debounce: Git nicht zuspammen

Das sleep 1 nach jedem Save ist ein Debounce.

Wenn du 'ne Datei in 'nem Editor speicherst, erzeugt das oft mehrere Dateisystem-Ereignisse auf einmal (temporäre Datei erstellen, umbenennen, alte löschen...). Ohne Debounce würden 3-4 Commits für 'nen einzigen Save rausgehen.

Das sleep 1 sagt: "warte 'ne Sekunde nach 'nem Save, bis die Welle sich beruhigt hat, bevor du wieder lauschst". Es fasst nahe Änderungen in 'nem einzigen Commit zusammen. Clever.

Und noch 'n periodischer Backup

Falls inotify mal was verpassen sollte, gibt's auch noch 'nen Save alle 5 Sekunden:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

Gürtel UND Hosenträger. Wir wollen den Plattenzustand auf keinen Fall verlieren.


Das clevere Detail: Nur ein Commit

Wenn du bei jeder Dateiänderung commitest, sammelst du... tausende Commits. In 'ner Stunde Session explodiert deine Git-History. Das Repo wird riesig. Widerlich.

Die Lösung ist elegant: wir amendieren den existierenden Commit, statt 'nen neuen zu erstellen.

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend bedeutet: "ersetz den letzten Commit durch diesen".

Dadurch hat der Branch filesystem IMMER nur 'nen einzigen Commit. Egal wie oft du speicherst. Es ist einfach 'n Snapshot des aktuellen Zustands, der immer und immer wieder force-gepusht wird.

Das flock ist 'ne Sperre: weil es zwei Save-Schleifen gibt (inotify + periodisch), muss verhindert werden, dass sie gleichzeitig Git starten und sich gegenseitig in die Quere kommen. Nur ein Git-Prozess zur Zeit.

Sauber.


Das sync_from_remote: Mehrere Sessions verwalten

Hier 'n Ding, an das du am Anfang nicht denkst: was, wenn du ZWEI Runs gleichzeitig startest? Oder wenn 'ne Session den Branch filesystem ändert, während 'ne andere läuft?

Das Script handhabt das mit 'nem sync_from_remote vor jedem Commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

Das --ff-only (Fast-Forward Only) ist wichtig: es bedeutet "merge NUR, wenn wir sauber vorrücken können, ohne 'nen Merge-Commit zu erstellen".

Wenn die beiden Branches auseinandergegangen sind (z.B. zwei Sessions haben verschiedene Dinge geändert), schlägt der Fast-Forward still fehl (2>/dev/null || true) und der lokale Zustand bleibt erhalten. Es ist kein perfektes Merge-System, aber es verhindert Korruption im einfachen Fall, wo nur eine Session läuft.

Ehrlich gesagt, du solltest nicht 3 Sessions parallel im selben Repo laufen lassen. Aber der Code versucht trotzdem, nicht zu explodieren, falls es passiert. Das ist Verteidigung.


Der APT-Cache: Schnell installieren

Es gibt 'n Detail im Workflow, das unscheinbar wirkt, aber gut durchdacht ist:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate und inotify-tools werden über 'ne Action installiert, die APT-Pakete cached.

Beim ersten Run werden sie runtergeladen und installiert. Bei späteren Runs werden sie aus dem GitHub-Actions-Cache wiederhergestellt -- schneller, kein erneuter Download nötig.

Und erinnerst du dich an das git clean -fdx -e .apt-cache von vorhin? Das hängt damit zusammen. Der Ordner .apt-cache wird vor dem Putzen geschützt, damit die Pakete, die du während deiner Session installierst, zumindest minimal überleben können.

Alles hält sich.


Die Scripts, die in /tmp versteckt werden

Noch 'n fieses, aber cleveres Detail. Ganz am Anfang des Scripts:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Die Scripts (update_readme.py, etc.) werden nach /tmp kopiert, BEVOR der Branch filesystem angefasst wird.

Warum? Weil wenn du git reset --hard auf den Branch filesystem machst (der am Anfang leer ist oder deine Platte enthält), verschwinden die .github/scripts-Dateien des Quell-Repos aus dem Workspace.

Aber das Script braucht sie, um das README zu updaten. Also versteckt es sie in /tmp:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Wenn du nicht dran denkst, fliegt's dir um die Ohren. Ich hab dran gedacht.


Die maßgeschneiderte Shell

Kleiner Komfort am Ende: Die Session gibt dir 'ne konfigurierte Shell, kein nacktes Bash.

Das prestart.sh kopiert 'ne custom .bashrc:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

Und diese .bashrc enthält 'nen farbigen Prompt, Aliase (ll, lla, rm -i), und vor allem 'nen cleveren Override von exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

Wenn du exit (oder Ctrl+D) eingibst, killt es sauber die tmate-Prozesse, bevor es schließt. Das verhindert, dass verwaiste tmate-Sessions auf der Maschine rumhängen.

Es gibt auch 'ne Funktion tmate-detach, falls du dich trennen willst, OHNE die Session zu killen (um dich später neu zu verbinden). Komfortdetail, aber es zeigt das Level an Sorgfalt.


Der selbst-neustartende tmate

Kleiner Komfort: Wenn du exit in deiner Shell eingibst, stirbt normalerweise die tmate-Session und du bist endgültig weg.

Aber hier ist tmate in 'ner while true-Schleife:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

Du exit? Die Session startet von selbst neu. Du kannst dich mit demselben Link neu verbinden. Stabile Wiederverbindung, sogar nach 'ner Trennung.

Ist bescheuert, aber macht's nutzbar.


Die Ein-Kommando-Wiederverbindung

Wie verbindest du dich nach 'ner Trennung neu, ohne jedes Mal in den Run-Logs rumzukruschen?

Die tmate-SSH-Adresse wird in 'ne Datei host.conf geschrieben, die selbst im Branch filesystem committed wird:

printf '%s' "${tmate_ssh#ssh }" > host.conf

Und weil diese Datei in Git ist, kannst du sie über die GitHub-API mit 'nem einzigen Kommando abrufen:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Du gibst das ein, es holt die aktuelle SSH-Adresse aus dem Repo und verbindet dich direkt. Selbst wenn die Adresse zwischen zwei Sessions gewechselt hat.

Erledigt.


Der komplette Flow

Fassen wir zusammen:

1. Du startest den Workflow (Push oder manueller Button)
2. GitHub gibt dir 'ne Ubuntu-VM
3. Das Script stellt die Platte aus dem Branch "filesystem" wieder her
4. Inotify beginnt, alle Änderungen zu überwachen
5. Periodic_save committed alle 5s als Backup
6. Tmate startet → generiert SSH-/Web-Links
7. Die Links werden ins README + host.conf geschrieben
8. Du verbindest dich mit SSH oder dem Web-Terminal
9. Du machst, was du willst (codieren, installieren, debuggen...)
   └── jede Dateiänderung = sofortiger Autosave nach Git
10. 6h später killt GitHub die VM
11. Aber deine Platte ist intakt im Branch "filesystem"
12. Du startest den Workflow neu → zurück zu Schritt 3, alles ist noch da

Ein VPS. Kostenlos. Mit persistenter Platte. Einfach mit Git und GitHub Actions.


OK, seien wir ehrlich: Die Limits

Es ist 'n Hack, kein echter VPS. Also:

  • Max 6h pro Run. Du musst den Workflow regelmäßig neu starten. Kein unendlicher Uptime.
  • Nichts für Produktion. Du wirst deine Site nicht darauf hosten. Es ist zum Explorieren, Entwickeln, Debuggen, Testen von Zeug in 'nem wegwerfbaren aber wiederherstellbaren Linux.
  • GitHub sieht alles. Es sind ihre Maschinen. Pack nichts Sensibles rein.
  • Halt das Repo privat. Du exponierst 'ne SSH-Shell. 'N öffentliches Repo = potenziell jeder kann sich verbinden. Schlechte Idee.
  • Es ist am Rand der Nutzungsbedingungen. GitHub Actions ist für CI/CD gedacht, nicht für kostenlose VPS. Also mit Maß verwenden, für legitime Zwecke, ohne zu missbrauchen.

Die wahre Achillesferse: Git hasst große Dateien

Es gibt 'ne technischere Grenze, und das ist die wichtigste, die du verstehen musst.

Git ist für Text gemacht, nicht für 'n Dateisystem.

Die persistente Platte lebt in 'nem Git-Branch. Also geht alles, was du speicherst, durch Git. Und Git:

  • kann mit großen Binärdateien schlecht umgehen (ein 2 GB Docker-Image in Git? vergiss es)
  • hat 'n Limit von 100 MB pro Datei auf GitHub (harte Grenze, drüber pusht es nicht)
  • empfiehlt, unter ~5 GB pro Repo zu bleiben

Also wenn du npm install in 'nem Projekt mit 500 MB node_modules machst, oder wenn du was baust, das schwere Binaries ausspuckt, wird der Push zum filesystem-Branch entweder extrem lahm oder scheitert komplett.

Das git commit --amend hilft (nur ein Commit, keine aufblasende History), aber es ändert nichts daran, dass 'ne 200 MB Datei nie durchkommt.

Kurz: Es funktioniert super für Code, Configs, kleine Dateien. Es funktioniert nicht, um große Daten oder binäre Artefakte zu speichern. Das musst du im Kopf behalten, wenn du in deiner Session arbeitest.

Es ist kein kompletter System-Snapshot

Noch 'ne wichtige Nuance: Der Branch filesystem sichert das Workspace (den Ordner des Repos), nicht das ganze System.

Wenn du apt install htop machst, landet das Binary in /usr/bin/htop, das AUSSERHALB des Workspace liegt. Es wird NICHT gesichert. Beim nächsten Run musst du es neu installieren.

Deshalb gibt es den APT-Cache und das prestart.sh: um die System-Umgebung bei jedem Start neu vorzubereiten, weil nur das Workspace überlebt.

Wenn du willst, dass deine Installationen überleben, musst du sie ins Workspace packen (z.B. in 'nem lokalen Ordner installieren statt systemweit). Das ist 'ne Denkweise, die du dir aneignen musst.


Kostenloser VPS vs echter VPS: Der Vergleich

repo-to-vps Echter VPS (5€/Monat)
Preis 0€ ~5-10€/Monat
Uptime 6h, neu starten 24/7
Platte Git-Branch, kleine Dateien echte SSD, mehrere GB
RAM ~7 GB (großzügig!) 1-2 GB oft
CPU 2-4 ordentliche Kerne 1-2 vCPU
Setup Template klonen manuelle Config
Persistenz nur Workspace komplettes System
Legitimität Grenze der AGB 100% sauber

Das Lustige ist, dass der GitHub-Runner bei den Rohdaten (RAM, CPU) oft BESSER ist als 'n 5€-VPS. Aber der 6h-Uptime und die auf das Workspace beschränkte Persistenz machen es zu 'nem Hackerspielzeug, nicht zu 'nem echten Server.

Zum Lernen, Testen, schnellen Linux-Debuggen in 'ner wiederherstellbaren Umgebung? Perfekt. Um irgendwas Ernsthaftes zu hosten? Hol dir 'nen echten VPS.

Aber für 'ne temporäre Linux-Umgebung, die du nach Belieben wiederherstellen kannst? Einfach genial.


Das Muster dahinter

Wenn du einen Schritt zurücktrittst, basieren repo-to-vps und der E-Mail-Bot (mein anderer Artikel) auf derselben Idee:

Git ist nicht nur ein Versionsverwalter. Es ist ein persistentes, kostenloses, versioniertes Speichersystem, das über 'ne API zugänglich ist.

Sobald du 'n zustandsloses System hast (GitHub Actions, 'n Worker, 'ne Serverless-Funktion) und 'nen Zustand zwischen zwei Ausführungen behalten willst, kann Git als "Platte" dienen.

  • Der E-Mail-Bot speichert 'ne lastId in 'nem Git-Tag.
  • repo-to-vps speichert 'n ganzes Dateisystem in 'nem Git-Branch.

Gleiches Muster, zwei Größenordnungen. Ein Wert auf der einen Seite, 'ne Platte auf der anderen.

Und das git commit --amend + Force-Push ist die gemeinsame Technik: du behältst 'nen einzigen Commit, der den aktuellen Zustand repräsentiert, der bei jedem Update überschrieben wird.

War nicht dafür gedacht. Aber es funktioniert. Und ist kostenlos.


Die 3 Dinge, die du dir merken solltest:

  1. Ein Git-Branch = 'ne persistente Festplatte -- Speichere dein Dateisystem in 'nem dedizierten Branch, stelle es beim Start wieder her, und du hast 'nen Zustand, der wegwerfbare Maschinen überlebt.

  2. Inotify + Git = Autosave in Echtzeit -- inotifywait überwacht Änderungen auf Kernel-Ebene und pusht sofort zu Git. Mit git commit --amend, um 'nen einzigen sauberen Commit zu behalten.

  3. Tmate verwandelt 'nen Runner in 'nen VPS -- Live-SSH auf 'ner GitHub-Actions-Maschine, mit automatischem Neustart und Ein-Kommando-Wiederverbindung über die GitHub-API.

Git als Festplatte, zweite Folge. Ich glaube, ich werde am Ende alles in Git-Branches speichern xD

Repo to VPS: превращаем GitHub Actions в бесплатный VPS с постоянным хранилищем

Как превратить runner GitHub Actions в постоянно работающий VPS, используя git в качестве постоянного хранилища -- tmate, inotify и commit --amend.

GitHub даёт тебе VPS бесплатно на 6ч. Я нашёл, как сделать его постоянным.

GitHub Actions даёт тебе бесплатные Linux-машины.

Типа, настоящие серверы Ubuntu. 2 ядра, 7 ГБ RAM, 14 ГБ диска. Бесплатно. На 6 часов за один run.

Единственная "проблема": после завершения run всё стирается. Машина одноразовая. Ты ставишь софт, кодишь, настраиваешь... и бац, в конце всё исчезает. Как будто ты ничего и не делал.

Кроме одного "но".

Кроме случая, когда ты используешь git как жёсткий диск.

И вот тут внезапно у тебя появляется бесплатный VPS с постоянным диском, который переживает запуски. Ты переподключаешься -- всё ещё на месте. Продолжаешь с того места, где остановился.

Это просто сломанный трюк. Дай объясню xD


Контекст: раннеры GitHub Actions

Когда ты запускаешь workflow GitHub Actions, GitHub выдаёт тебе VM.

Она создана для сборки кода, запуска тестов, деплоя. Workflow работает, делает своё дело, и машина уничтожается.

Но ничто не мешает тебе заняться чем-то другим на этой VM. Открыть SSH-шелл и использовать как сервер.

Фишка в том, что эти машины -- stateless и временные:

  • Временные: максимум 6ч на один run (timeout-minutes: 360, потолок GitHub)
  • Stateless: всё стирается в конце

Чтобы превратить это в полноценный VPS, нужно решить две проблемы:

  1. Как подключаться к ней в реальном времени?
  2. Как сохранить диск между запусками?

Вот тут начинается хак.


Проблема 1: живой SSH через tmate

tmate -- это форк tmux, который создаёт расшариваемую SSH-сессию.

Ты запускаешь его на машине, он генерирует две ссылки:

  • URL для SSH (ssh [email protected])
  • URL для веба (терминал в браузере)

Ты подключаешься по одной из ссылок -- и бум, ты в шелле на машине. В реальном времени.

Workflow запускает tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

И эти ссылки пишутся прямо в README репозитория через Python-скрипт. Ты открываешь репозиторий, видишь ссылку для подключения, кликаешь. И вот ты уже в своём VPS.

Первая проблема решена. Но вторая -- просто безумная.


Проблема 2: git как жёсткий диск

Вот это реально жесть.

Машина стирается при каждом запуске. Поэтому мы храним файловую систему в отдельной git-ветке под названием filesystem.

При старте скрипт восстанавливает состояние из этой ветки:

filesystem_branch="filesystem"
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

Ветка filesystem -- ЭТО твой жёсткий диск. Твои файлы, твои установки, твои конфиги -- всё внутри.

Понимаешь? Машина одноразовая, но диск живёт в git. Ты перезапускаешь workflow -- диск восстанавливается, ты продолжаешь ровно с того места, где остановился.

Это как VPS с гибернацией. Только гибернация -- это git-репозиторий xD

Первый запуск: создаём пустой диск

При самом первом запуске ветка filesystem ещё не существует. Её нужно создать. И это нетривиально:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan -- ключевой момент. Ветка-сирота -- это ветка без какой-либо истории -- как будто ты начинаешь с пустого репозитория.

Зачем сирота? Потому что ты НЕ хочешь, чтобы твой постоянный диск тащил за собой всю историю исходного кода. Диск -- это отдельная сущность со своей жизнью. Он начинается девственно чистым.

А git ls-remote --exit-code в начале -- это просто аккуратная проверка: "существует ли ветка на remote?". Если да -- ничего не трогаем. Если нет -- создаём. Идемпотентно, как мы любим.

Выборочный git clean: защита кэшей

На этой строке стоит остановиться:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx удаляет ВСЁ, что не отслеживается git'ом. Обычно это жёстко -- вычищает workspace под ноль.

Но флаги -e (exclude) защищают некоторые вещи:

  • .apt-cache → кэш APT-пакетов (вернёмся к этому, это умно)
  • .cache → общий кэш
  • host.conf → SSH-адрес сессии
  • tmate.sock → сокет текущей tmate-сессии

Если бы ты удалил эти файлы, ты сломал бы активную сессию или потерял бы кэш. Так что мы их щадим при сбросе.

Тупая деталь на первый взгляд, но без этого всё ломается.


Автосохранение: inotify следит за всем

Итак, как файлы попадают в ветку filesystem?

Ответ: вотчер, который следит за ВСЕМИ изменениями файлов и автоматически делает commit/push.

Магический инструмент -- inotifywait (из пакета inotify-tools). Он следит за файловой системой на уровне ядра и срабатывает при любом изменении файла.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

Разберём флаги inotify, потому что каждый важен:

  • -r → рекурсивно, следит за всеми подпапками
  • -e modify,create,delete,move → реагирует на 4 типа событий (изменение, создание, удаление, перемещение)
  • --exclude '...' → регулярка для игнорирования определённых файлов

--exclude критически важен. Смотри, что он игнорирует:

  • .git → очевидно, иначе каждый commit вызывал бы автосохранение, которое вызывало бы commit... бесконечный цикл. Катастрофа.
  • .apt-cache и .cache → кэши, которые постоянно меняются и которые мы не хотим спамить в git
  • host.conf и tmate.sock → файлы сессии, которые постоянно меняются
  • .gitignore, .txt.swp → временные файлы (.swp -- это файлы редактирования vim)

Без этого exclude автосохранение будет срабатывать по кругу на собственные изменения. .git в списке -- это ТА самая строка, которая спасает тебя от выстрела себе в ногу.

Ты меняешь файл? inotify детектирует мгновенно, делает commit, push. Меньше чем за секунду твоё изменение оказывается в ветке filesystem.

Ты устанавливаешь софт, пишешь код, трогаешь конфиги -- всё сохраняется в реальном времени, автоматически, без каких-либо действий с твоей стороны.

У тебя буквально автоматическая система сохранения всего диска. Сломанный трюк.

Debounce: не спамим git

sleep 1 после каждого сохранения -- это debounce.

Когда ты сохраняешь файл в редакторе, обычно генерируется несколько файловых событий очередью (создание временного файла, переименование, удаление старого...). Без debounce ты бы получал 3-4 commit'а на одно сохранение.

sleep 1 говорит: "подожди секунду после сохранения, дай очереди устаканиться, прежде чем слушать снова". Это группирует близкие изменения в один commit. Умно.

И ещё периодическое сохранение для подстраховки

На случай, если inotify что-то пропустит, есть ещё сохранение каждые 5 секунд:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

Пояс и подтяжки. Мы категорически не хотим терять состояние диска.


Хитрый трюк: всего один commit

Если делать commit при каждом изменении файла, накопится... тысячи коммитов. За час сессии твоя git-история взорвётся. Репозиторий станет огромным. Это отвратительно.

Решение элегантное: мы правим существующий commit вместо создания нового.

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend означает: "замени последний commit на этот".

В итоге ветка filesystem ВСЕГДА содержит ровно один commit. Неважно, сколько раз ты сохраняешься. Это просто снапшот текущего состояния, снова и снова отправленный force-push'ем.

flock -- это блокировка: так как есть два цикла сохранения (inotify + периодический), нужно избежать одновременного запуска git. Только один git-процесс за раз.

Чисто.


sync_from_remote: обработка нескольких сессий

Вот ещё момент, о котором не задумываешься вначале: что, если запустить ДВА run одновременно? Или если одна сессия изменила ветку filesystem пока работает другая?

Скрипт обрабатывает это с помощью sync_from_remote перед каждым commit'ом:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only) важен: он означает "merge ТОЛЬКО если можно сделать чистый fast-forward, без создания merge-commit".

Если ветки разошлись (например, две сессии изменили разные вещи), fast-forward молча проваливается (2>/dev/null || true) и оставляем локальное состояние. Это не идеальная система merge, но она предотвращает повреждения в простом случае, когда работает только одна сессия.

Честно говоря, не стоит запускать 3 сессии параллельно в одном репозитории. Но код хотя бы пытается не взорваться, если это произойдёт. Это защита.


APT-кэш: быстрая установка

В workflow есть одна деталь, которая выглядит незначительной, но хорошо продумана:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate и inotify-tools установлены через action, который кэширует APT-пакеты.

При первом запуске -- скачивает и устанавливает. При последующих -- восстанавливает из кэша GitHub Actions -- быстрее, не нужно перекачивать.

И помнишь git clean -fdx -e .apt-cache из предыдущего раздела? Это связано. Папка .apt-cache защищена от очистки именно для того, чтобы пакеты, которые ты устанавливаешь во время сессии, могли минимально сохраняться.

Всё держится.


Скрипты, спрятанные в /tmp

Ещё одна коварная, но умная деталь. В самом начале скрипта:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Скрипты (update_readme.py и т.д.) копируются в /tmp ДО того, как скрипт трогает ветку filesystem.

Зачем? Потому что когда ты делаешь git reset --hard на ветку filesystem (которая изначально пуста или содержит твой диск), файлы .github/scripts из исходного репозитория исчезают из workspace.

Но скрипт нужен, чтобы обновлять README. Поэтому прячет их в /tmp:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Если не подумать, скрипт исчезнет. Я подумал.


Кастомный шелл

Маленький комфорт под конец: сессия даёт тебе настроенный шелл, а не голый bash.

prestart.sh копирует кастомный .bashrc:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

И этот .bashrc содержит цветной prompt, алиасы (ll, lla, rm -i), и главное -- хитрый переопределённый exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

Когда ты набираешь exit (или Ctrl+D), он аккуратно убивает процессы tmate перед закрытием. Это предотвращает появление висящих tmate-сессий на машине.

Есть ещё функция tmate-detach, если хочешь отключиться БЕЗ завершения сессии (чтобы переподключиться позже). Деталь комфорта, но показывает уровень заботы.


tmate, который перезапускается сам

Маленький комфорт: если ты наберёшь exit в своём шелле, обычно сессия tmate умирает и ты отключаешься навсегда.

Но здесь tmate находится в цикле while true:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

Ты нажал exit? Сессия перезапускается сама. Ты можешь переподключиться по той же ссылке. Стабильное переподключение даже после отвала.

Тупо, но делает штуку юзабельной.


Переподключение одной командой

Как переподключиться после отвала, не лазая каждый раз по логам run'а?

SSH-адрес tmate записан в файл host.conf, который, в свою очередь, закоммичен в ветку filesystem:

printf '%s' "${tmate_ssh#ssh }" > host.conf

И так как этот файл в git, ты можешь получить его через GitHub API одной командой:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Ты запускаешь это, оно берёт актуальный SSH-адрес из репозитория и подключает напрямую. Даже если адрес изменился между сессиями.

Готово.


Полный флоу

Подытожим:

1. Ты запускаешь workflow (push или ручная кнопка)
2. GitHub выдаёт тебе VM с Ubuntu
3. Скрипт восстанавливает диск из ветки "filesystem"
4. inotify начинает следить за всеми изменениями
5. periodic_save делает commit каждые 5с как бэкап
6. tmate запускается → генерирует SSH/веб-ссылки
7. Ссылки пишутся в README + host.conf
8. Ты подключаешься через SSH или веб-терминал
9. Ты делаешь что хочешь (кодишь, устанавливаешь, дебажишь...)
   └── каждое изменение файла = мгновенное автосохранение в git
10. Через 6 часов GitHub убивает VM
11. Но твой диск цел в ветке "filesystem"
12. Ты перезапускаешь workflow → возврат к шагу 3, всё ещё на месте

VPS. Бесплатно. С постоянным диском. Просто с помощью git и GitHub Actions.


Ладно, будем честны: ограничения

Это хак, а не настоящий VPS. Поэтому:

  • Максимум 6ч на run. Нужно перезапускать workflow регулярно. Бесконечного аптайма нет.
  • Не для прода. Ты не будешь хостить там свой сайт. Это для исследования, разработки, отладки, тестирования чего-то в одноразовом, но восстанавливаемом Linux.
  • GitHub всё видит. Это их машины. Не клади туда ничего чувствительного.
  • Держи репо приватным. Ты открываешь SSH-доступ. Публичный репозиторий = кто угодно потенциально может подключиться. Плохая идея.
  • Это на грани условий использования. GitHub Actions создан для CI/CD, а не для бесплатного VPS. Так что используй с умом, для легитимных целей, без злоупотреблений.

Настоящая ахиллесова пята: git не любит большие файлы

Есть ограничение более техническое, и его важнее всего понять.

Git создан для текста, а не для файловой системы.

Постоянный диск живёт в git-ветке. Поэтому всё, что ты сохраняешь, проходит через git. А git:

  • плохо работает с большими бинарными файлами (Docker-образ на 2 ГБ в git? забудь)
  • имеет лимит 100 МБ на файл на GitHub (жёсткий лимит, больше не запушить)
  • рекомендует оставаться в пределах ~5 ГБ на репозиторий

Так что если ты npm install делаешь проект с 500 МБ node_modules, или собираешь что-то, что выдаёт тяжёлые бинарники, push в filesystem либо будет тормозить адски, либо вообще провалится.

git commit --amend помогает (один commit, без раздувания истории), но это не меняет того факта, что файл на 200 МБ никогда не пройдёт.

В общем: отлично работает для кода, конфигов, маленьких файлов. Не работает для хранения больших данных или бинарных артефактов. Нужно держать это в голове, когда работаешь в сессии.

Это не полный снапшот системы

Ещё важный нюанс: ветка filesystem сохраняет workspace (папку репозитория), а не всю систему.

Если ты сделаешь apt install htop, бинарник попадёт в /usr/bin/htop, который находится ВНЕ workspace. Так что он НЕ будет сохранён. При следующем запуске придётся устанавливать заново.

Именно для этого нужны APT-кэш и prestart.sh: чтобы переподготавливать системное окружение при каждом запуске, так как сохраняется только workspace.

Если хочешь, чтобы твои установки переживали перезапуск, нужно ставить их в workspace (например, устанавливать в локальную папку, а не в систему). Это определённая гимнастика, к которой нужно привыкнуть.


Бесплатный VPS vs настоящий VPS: сравнение

repo-to-vps Настоящий VPS (5€/мес)
Цена 0€ ~5-10€/мес
Аптайм 6ч, нужно перезапускать 24/7
Диск git-ветка, маленькие файлы настоящий SSD, несколько ГБ
RAM ~7 ГБ (щедро!) 1-2 ГБ обычно
CPU 2-4 приличных ядра 1-2 vCPU
Настройка клонировать шаблон ручная настройка
Постоянство только workspace полная система
Легитимность на грани с CGU 100% чисто

Забавно, что по сырым характеристикам (RAM, CPU) раннер GitHub часто ЛУЧШЕ, чем VPS за 5€. Но аптайм в 6 часов и постоянство только workspace -- это то, что делает его игрушкой хакера, а не настоящим сервером.

Для обучения, тестирования, быстрой отладки чего-то Linux в восстанавливаемом окружении? Идеально. Для хостинга чего-то серьёзного? Купи настоящий VPS.

Но для временного Linux-окружения, которое можно восстанавливать по желанию? Просто гениально.


Паттерн, стоящий за всем этим

Если посмотреть шире, repo-to-vps и бот для email (моя другая статья) построены на одной и той же идее:

Git -- это не просто система контроля версий. Это постоянное, бесплатное, версионированное хранилище с API-доступом.

Как только у тебя появляется stateless-система (GitHub Actions, Worker, serverless-функция) и желание сохранять состояние между запусками, git может выступить в роли "диска".

  • Бот для email хранит lastId в git-теге.
  • repo-to-vps хранит целую файловую систему в git-ветке.

Тот же паттерн, два масштаба. С одной стороны -- значение, с другой -- диск.

И git commit --amend + force-push -- это общая техника: ты держишь один commit, представляющий текущее состояние, перезаписываемый при каждом обновлении.

Изначально не для этого создавалось. Но работает. И бесплатно.


3 вещи, которые нужно запомнить:

  1. Git-ветка = постоянный жёсткий диск -- Храни свою файловую систему в отдельной ветке, восстанавливай при запуске -- и у тебя будет состояние, переживающее одноразовые машины.

  2. inotify + git = автосохранение в реальном времени -- inotifywait следит за изменениями на уровне ядра и мгновенно пушит в git. С git commit --amend для поддержания одного чистого коммита.

  3. tmate превращает раннер в VPS -- Живой SSH на машине GitHub Actions, с автоматическим перезапуском и переподключением одной командой через GitHub API.

Git как жёсткий диск, второй эпизод. Кажется, я закончу тем, что буду хранить вообще всё в git-ветках xD

Repo to VPS: convierte GitHub Actions en un VPS gratuito con almacenamiento persistente

Cómo convertir un runner de GitHub Actions en un VPS siempre activo usando git como almacenamiento persistente -- tmate, inotify y commit --amend.

GitHub te regala un VPS gratis por 6h. Encontré cómo hacerlo permanente.

GitHub Actions te da máquinas Linux gratis.

O sea, servidores Ubuntu de verdad. 2 núcleos, 7 GB de RAM, 14 GB de disco. Gratis. Por 6h por run.

El único "problema": al final del run, todo se borra. La máquina es desechable. Instalas cosas, programs, configuras... y puf, al final todo desaparece. Como si no hubieras hecho nada.

Salvo si.

Salvo si usas git como disco duro.

Y entonces, de repente, tienes un VPS gratis con un disco persistente que sobrevive a los runs. Te reconectas, todo sigue ahí. Retomas donde lo dejaste.

Es completamente roto. Déjame explicarte xD


El contexto: los runners de GitHub Actions

Cuando lanzas un workflow de GitHub Actions, GitHub te da una VM.

Está hecha para compilar tu código, lanzar tus tests, hacer deploy. El workflow corre, hace su trabajo, y la máquina se destruye.

Pero nada te impide hacer otra cosa con esa VM. Abrir un shell SSH y usarla como servidor.

El tema es que estas máquinas son stateless y temporales:

  • Temporal: 6h máx por run (timeout-minutes: 360, el límite de GitHub)
  • Stateless: todo se borra al final

Así que para convertirla en un VPS funcional, hay que resolver dos problemas:

  1. ¿Cómo conectarse en tiempo real?
  2. ¿Cómo mantener el disco entre runs?

Ahí es cuando se vuelve un hack.


Problema 1: SSH en vivo con tmate

tmate es un fork de tmux que crea una sesión SSH compartible.

Lo ejecutas en una máquina y te genera dos enlaces:

Te conectas con uno de esos enlaces, y boom, estás en un shell en la máquina. En tiempo real.

El workflow entonces lanza tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Y esos enlaces se escriben directo en el README del repo con un script Python. Abres tu repo, ves el enlace de conexión, haces clic. Ya estás en tu VPS.

Primer problema resuelto. Pero el segundo es el que realmente está loco.


Problema 2: git como disco duro

Aquí viene lo enfermizo.

La máquina se borra en cada run. Así que guardamos el sistema de archivos en una rama git dedicada, llamada filesystem.

Al iniciar, el script restaura el estado desde esa rama:

filesystem_branch="filesystem"
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

La rama filesystem ES tu disco duro. Tus archivos, tus instalaciones, tus configs -- todo está ahí.

¿Captas? La máquina es desechable, pero el disco vive en git. Vuelves a lanzar el workflow, el disco se restaura, retomas exactamente donde estabas.

Es como un VPS que hiberna. Solo que la hibernación es un repo git xD

Primer lanzamiento: crear el disco vacío

En el primer run, la rama filesystem todavía no existe. Hay que crearla. Y no es trivial:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

El git checkout --orphan es la clave. Una rama huérfana es una rama sin ningún historial -- como si empezaras desde un repo vacío.

¿Por qué huérfana? Porque NO quieres que tu disco persistente arrastre todo el historial de tu código fuente. El disco es algo aparte, que tiene su propia vida. Empieza virgen.

Y el git ls-remote --exit-code al principio, es solo una verificación limpia: "¿la rama ya existe en el remoto?". Si sí, no se toca nada. Si no, se crea. Idempotente, como nos gusta.

El git clean selectivo: proteger las caches

Esta línea merece que nos detengamos:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx borra TODO lo que no está trackeado por git. Normalmente es violento -- limpia el workspace a fondo.

Pero los -e (exclude) protegen ciertas cosas:

  • .apt-cache → la caché de paquetes APT (volveremos a esto, es inteligente)
  • .cache → caché genérica
  • host.conf → la dirección SSH de la sesión
  • tmate.sock → el socket de la sesión tmate activa

Si limpiaras esos archivos, romperías la sesión activa o perderías tu caché. Así que los perdonas durante el reset.

Un detalle tonto a primera vista, pero sin esto todo explota.


El autosave: inotify vigilando todo

Bueno, pero ¿cómo terminan los archivos en la rama filesystem?

Respuesta: un watcher que vigila TODOS los cambios de archivos y hace commit/push automáticamente.

La herramienta mágica es inotifywait (del paquete inotify-tools). Vigila el sistema de archivos a nivel de kernel y se dispara cuando un archivo cambia.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1
  done
}

autosave &

Analicemos los flags de inotify, porque cada uno cuenta:

  • -r → recursivo, vigila todas las subcarpetas
  • -e modify,create,delete,move → reacciona a estos 4 tipos de eventos (modificación, creación, borrado, movimiento)
  • --exclude '...' → una regex para ignorar ciertos archivos

El --exclude es crucial. Mira lo que ignora:

  • .git → obviamente, porque si no cada commit dispararía un autosave que dispararía otro commit... bucle infinito. Catástrofe.
  • .apt-cache y .cache → las caches, que cambian todo el tiempo y no quieres spamear en git
  • host.conf y tmate.sock → los archivos de sesión, que se mueven constantemente
  • .gitignore, .txt.swp → los archivos temporales (los .swp son los archivos de edición de vim)

Sin ese exclude, terminarías con un autosave que se dispara en bucle por sus propios cambios. El .git en la lista, es LA línea que evita que te dispares en el pie.

¿Modificas un archivo? inotify lo detecta al instante, hace commit, hace push. En menos de un segundo, tu cambio está en la rama filesystem.

Instalas algo, escribes código, tocas una configuración -- todo se guarda en tiempo real, automáticamente, sin que hagas nada.

Literalmente tienes un sistema de guardado automático del disco entero. Roto.

El debounce: no spamear git

El sleep 1 después de cada save es un debounce.

Cuando guardas un archivo en un editor, a menudo genera varios eventos del sistema de archivos en ráfaga (creación de un archivo temp, rename, borrado del anterior...). Sin debounce, dispararías 3-4 commits por un solo guardado.

El sleep 1 dice: "espera un segundo después de un save, mientras la ráfaga se calma, antes de volver a escuchar". Así agrupa los cambios cercanos en un solo commit. Inteligente.

Y un guardado periódico adicional

Por si inotify se perdiera algo, también hay un save cada 5 segundos:

periodic_save() {
  while true; do
    sync_from_remote
    sleep 5
    commit_and_push
  done
}

periodic_save &

Cinturón Y tirantes. Sobre todo no queremos perder el estado del disco.


El detalle inteligente: un solo commit

Si haces commit en cada cambio de archivo, acumularías... miles de commits. En una hora de sesión, tu historial git explota. El repo se vuelve enorme. Es asqueroso.

La solución es elegante: enmiendas el commit existente en lugar de crear uno nuevo.

commit_and_push() {
  (
    flock -n 200 || return

    git add -A
    git reset -- .github/workflows/ .github/scripts/

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend significa: "reemplaza el último commit por este".

Así la rama filesystem tiene SIEMPRE un solo commit. No importa cuántas veces guardes. Es solo un snapshot del estado actual, con force-push una y otra vez.

El flock es un candado: como hay dos bucles de save (inotify + periódico), hay que evitar que ejecuten git al mismo tiempo y se pisen. Un solo proceso git a la vez.

Limpio.


El sync_from_remote: manejar varias sesiones

Oye, algo en lo que no piensas al principio: ¿y si lanzas DOS runs al mismo tiempo? ¿O si una sesión modifica la rama filesystem mientras otra está corriendo?

El script lo maneja con un sync_from_remote antes de cada commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

El --ff-only (fast-forward only) es importante: significa "merge SOLO si se puede avanzar limpiamente, sin crear un commit de merge".

Si las dos ramas han divergido (ej. dos sesiones modificaron cosas diferentes), el fast-forward falla silenciosamente (2>/dev/null || true) y se mantiene el estado local. No es un sistema de merge perfecto, pero evita corrupciones en el caso simple donde solo corre una sesión.

Honestamente, no deberías lanzar 3 sesiones en paralelo en el mismo repo. Pero el código igual intenta no explotar si pasa. Es defensivo.


La caché APT: instalar rápido

Hay un detalle en el workflow que no parece gran cosa pero está bien pensado:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate e inotify-tools se instalan a través de una acción que cachea los paquetes APT.

En el primer run, descarga e instala. En runs siguientes, se restaura desde la caché de GitHub Actions -- más rápido, sin necesidad de descargar de nuevo.

¿Y te acuerdas del git clean -fdx -e .apt-cache de hace rato? Está relacionado. La carpeta .apt-cache está protegida de la limpieza precisamente para que los paquetes que instalas durante tu sesión puedan persistir un mínimo.

Todo se tiene.


Los scripts escondidos en /tmp

Otro detalle retorcido pero inteligente. Al principio del script:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Los scripts (update_readme.py, etc.) se copian a /tmp ANTES de tocar la rama filesystem.

¿Por qué? Porque cuando haces el git reset --hard hacia la rama filesystem (que está vacía al principio, o que contiene tu disco), los archivos .github/scripts del repo fuente desaparecen del workspace.

Pero los necesita para actualizar el README. Así que los esconde en /tmp, fuera del alcance de git:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Si no piensas en ello, tu script desaparece. Yo sí pensé.


El shell a medida

Pequeño comfort final: la sesión te da un shell configurado, no un bash pelado.

El prestart.sh copia un .bashrc personalizado:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

Y ese .bashrc contiene un prompt colorido, alias (ll, lla, rm -i), y sobre todo algo ingenioso: un override de exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

bind -x '"\C-d": "exit"'

Cuando escribes exit (o Ctrl+D), mata limpiamente los procesos tmate antes de cerrar. Así evitas dejar sesiones tmate zombie tiradas en la máquina.

También hay una función tmate-detach por si quieres desconectarte SIN matar la sesión (para reconectarte después). Detalle de comfort, pero muestra el nivel de cuidado.


El tmate que se reinicia solo

Pequeño comfort: si escribes exit en tu shell, normalmente la sesión tmate muere y te desconectas para siempre.

Salvo que aquí, tmate está en un bucle while true:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done

  echo "tmate session ended; restarting..."
done

¿exit? La sesión se reinicia sola. Puedes reconectarte con el mismo enlace. Reconexión estable, incluso después de una desconexión.

Es absurdo, pero lo hace usable.


La reconexión en un comando

¿Cómo te reconectas después de una desconexión, sin tener que revisar los logs del run cada vez?

La dirección SSH de tmate se escribe en un archivo host.conf, que a su vez está commiteado en la rama filesystem:

printf '%s' "${tmate_ssh#ssh }" > host.conf

Y como ese archivo está en git, puedes recuperarlo vía la API de GitHub con un solo comando:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Lanzas esto, va a buscar la dirección SSH actual en el repo, y te conecta directamente. Incluso si la dirección cambió entre sesiones.

Listo.


El flujo completo

Recapitulemos:

1. Lanzas el workflow (push o botón manual)
2. GitHub te da una VM Ubuntu
3. El script restaura el disco desde la rama "filesystem"
4. inotify empieza a vigilar todos los cambios
5. periodic_save hace commit cada 5s como backup
6. tmate arranca → genera los enlaces SSH/web
7. Los enlaces se escriben en el README + host.conf
8. Te conectas con ssh o el terminal web
9. Haces lo que quieras (programar, instalar, debug...)
   └── cada cambio de archivo = autosave instantáneo a git
10. 6h después, GitHub mata la VM
11. Pero tu disco está intacto en la rama "filesystem"
12. Vuelves a lanzar el workflow → vuelta al paso 3, todo sigue ahí

Un VPS. Gratis. Con disco persistente. Solo con git y GitHub Actions.


Vale, seamos honestos: los límites

Es un hack, no un VPS de verdad. Así que:

  • 6h máx por run. Hay que relanzar el workflow regularmente. Nada de uptime infinito.
  • No para producción. No vas a hostear tu sitio ahí. Es para explorar, desarrollar, debuggear, probar algo en un Linux desechable pero recuperable.
  • GitHub lo ve todo. Son sus máquinas. No pongas nada sensible.
  • Mantén el repo privado. Estás exponiendo un shell SSH. Un repo público = cualquiera puede potencialmente conectarse. Mala idea.
  • Está al límite de los términos de uso. GitHub Actions es para CI/CD, no para VPS gratis. Así que úsalo con moderación, para cosas legítimas, sin abusar.

El verdadero talón de Aquiles: git odia los archivos grandes

Hay un límite más técnico, y es el más importante de entender.

Git está hecho para texto, no para un sistema de archivos.

El disco persistente vive en una rama git. Así que todo lo que guardas pasa por git. Y git:

  • maneja mal los archivos binarios grandes (¿una imagen Docker de 2 GB en git? olvídalo)
  • tiene un límite de 100 MB por archivo en GitHub (hard limit, no hace push si lo superas)
  • recomienda mantenerse bajo ~5 GB por repo

Así que si haces npm install de un proyecto con 500 MB de node_modules, o si compilas algo que genera binarios pesados, el push a filesystem o va a ir lentísimo, o va a fallar directamente.

El git commit --amend ayuda (un solo commit, sin historial que crezca), pero no cambia el hecho de que un archivo de 200 MB nunca pasará.

En resumen: funciona genial para código, configuraciones, archivos pequeños. No funciona para guardar datos grandes o artefactos binarios. Hay que tenerlo en cuenta sobre lo que haces en tu sesión.

No es un snapshot completo del sistema

Otro matiz importante: la rama filesystem guarda el workspace (la carpeta del repo), no todo el sistema.

Si haces apt install htop, el binario va a /usr/bin/htop, que está FUERA del workspace. Por lo tanto NO se guardará. En el próximo run, hay que reinstalarlo.

Por eso tenemos la caché APT y el prestart.sh: para re-preparar el entorno del sistema en cada inicio, ya que solo el workspace persiste.

Si quieres que tus instalaciones sobrevivan, hay que ponerlas en el workspace (ej. instalar en una carpeta local en lugar de en todo el sistema). Es una gimnasia a la que hay que acostumbrarse.


VPS gratis vs VPS de verdad: la comparativa

repo-to-vps VPS de verdad (5€/mes)
Precio 0€ ~5-10€/mes
Uptime 6h, hay que relanzar 24/7
Disco rama git, archivos pequeños SSD real, varios GB
RAM ~7 GB (¡generoso!) 1-2 GB a menudo
CPU 2-4 núcleos decentes 1-2 vCPU
Setup clonar un template configuración manual
Persistencia solo workspace sistema completo
Legitimidad al límite de los TOS 100% legal

Lo gracioso es que en especificaciones brutas (RAM, CPU), el runner de GitHub suele ser MEJOR que un VPS de 5€. Pero el uptime de 6h y la persistencia limitada al workspace es lo que lo convierte en un juguete de hacker.

¿Para aprender, probar, debuguear algo Linux rápido en un entorno recuperable? Perfecto. ¿Para hostear algo serio? Cógete un VPS de verdad.


El patrón detrás de todo esto

Si miras con perspectiva, repo-to-vps y el bot de email (mi otro artículo) se basan en la misma idea:

Git no es solo un gestor de versiones. Es un sistema de almacenamiento persistente, gratuito, versionado, accesible vía API.

En cuanto tienes un sistema stateless (GitHub Actions, un Worker, una función serverless) y quieres mantener un estado entre dos ejecuciones, git puede servir como "disco".

  • El bot de email guarda un lastId en un tag de git.
  • repo-to-vps guarda un sistema de archivos entero en una rama de git.

Mismo patrón, dos escalas. Un valor de un lado, un disco del otro.

Y el git commit --amend + force-push es la técnica común: mantienes un solo commit que representa el estado actual, sobrescrito en cada actualización.

No fue hecho para esto. Pero funciona. Y es gratis.


Las 3 cosas que recordar:

  1. Una rama git = un disco duro persistente -- Guarda tu sistema de archivos en una rama dedicada, restaura al iniciar, y tienes un estado que sobrevive a máquinas desechables.

  2. inotify + git = autosave en tiempo real -- inotifywait vigila los cambios a nivel de kernel y hace push a git al instante. Con git commit --amend para mantener un solo commit limpio.

  3. tmate transforma un runner en VPS -- SSH en vivo en una máquina de GitHub Actions, con reinicio automático y reconexión en un comando vía la API de GitHub.

Git como disco duro, segundo episodio. Creo que voy a terminar guardándolo todo en ramas de git xD

Repo to VPS : transformar GitHub Actions em VPS gratuito com armazenamento persistente

Como transformar um runner do GitHub Actions em um VPS permanente com git como armazenamento persistente -- tmate, inotify e commit --amend.

GitHub te dá um VPS grátis por 6h. Descobri como torná-lo permanente.

GitHub Actions te dá máquinas Linux gratuitas.

Tipo, servidores Ubuntu de verdade. 2 núcleos, 7 GB de RAM, 14 GB de disco. Grátis. Por 6h por execução.

O único "problema": no final da execução, tudo é apagado. A máquina é descartável. Você instala coisas, programa, configura... e puff, no final tudo desaparece. Como se não tivesse feito nada.

A menos que.

A menos que você use git como disco rígido.

E aí, de repente, você tem um VPS gratuito com um disco persistente que sobrevive às execuções. Você se reconecta, tudo ainda está lá. Você retoma de onde parou.

É completamente louco. Deixa eu te explicar xD


O contexto: os runners do GitHub Actions

Quando você executa um workflow do GitHub Actions, o GitHub te dá uma VM.

É feito para buildar seu código, rodar seus testes, fazer deploy. O workflow roda, faz seu trabalho, e a máquina é destruída.

Mas nada te impede de fazer outras coisas com essa VM. Tipo, abrir um shell SSH nela e usar como servidor.

O negócio é que essas máquinas são stateless e temporárias:

  • Temporária: 6h no máximo por execução (timeout-minutes: 360, o limite do GitHub)
  • Stateless: tudo é apagado no final

Então para transformar isso num VPS utilizável, precisa resolver dois problemas:

  1. Como se conectar nela em tempo real?
  2. Como manter o disco entre duas execuções?

Aí vira um hack sujo.


Problema 1: o SSH ao vivo com tmate

tmate é um fork do tmux que cria uma sessão SSH compartilhável.

Você executa ele numa máquina, ele gera dois links:

Você se conecta com um desses links, e boom, você está num shell na máquina. Em tempo real.

O workflow então inicia o tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# pega os links de conexão
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

E esses links são escritos direto no README do repo por um script Python. Você abre seu repo, vê o link de conexão, clica. Pronto, você está no seu VPS.

Primeiro problema resolvido. Mas é o segundo que é realmente louco.


Problema 2: git como disco rígido

Aqui vai o bagulho doido.

A máquina é apagada a cada execução. Então armazenamos o sistema de arquivos num branch git dedicado, chamado filesystem.

Na inicialização, o script restaura o estado a partir desse branch:

filesystem_branch="filesystem"

# pega o branch filesystem do remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restaura o workspace a partir desse branch
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

O branch filesystem É o seu disco rígido. Seus arquivos, suas instalações, suas configurações -- tudo está nele.

Saca só? A máquina é descartável, mas o disco vive no git. Você reinicia o workflow, o disco é restaurado, você retorna exatamente de onde parou.

É como um VPS que hiberna. Só que a hibernação é um repositório git xD

Primeira execução: criar o disco vazio

Na primeira execução, o branch filesystem ainda não existe. Precisa criá-lo. E não é trivial:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

O git checkout --orphan é a chave. Um branch órfão é um branch sem nenhum histórico -- como se você estivesse começando de um repositório vazio.

Por que órfão? Porque você NÃO quer que seu disco persistente carregue todo o histórico do seu código fonte. O disco é uma coisa separada, que tem vida própria. Ele começa virgem.

E o git ls-remote --exit-code no início é só uma verificação limpa: "o branch já existe no remote?". Se sim, não mexe em nada. Se não, cria. Idempotente, como a gente gosta.

O git clean seletivo: proteger os caches

Essa linha merece uma pausa:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx remove TUDO que não é trackeado pelo git. Normalmente é violento -- limpa o workspace por completo.

Mas os -e (exclude) protegem algumas coisas:

  • .apt-cache → o cache dos pacotes APT (vamos voltar nisso, é esperto)
  • .cache → cache genérico
  • host.conf → o endereço SSH da sessão
  • tmate.sock → o socket da sessão tmate atual

Se você limpasse esses arquivos, quebraria a sessão ativa ou perderia seu cache. Então eles são poupados durante o reset.

Um detalhe besta à primeira vista, mas sem isso tudo quebra.


O autosave: inotify vigiando tudo

Beleza, mas como os arquivos vão parar no branch filesystem?

Resposta: um watcher que monitora TODAS as alterações de arquivos e faz commit/push automaticamente.

A ferramenta mágica é inotifywait (do pacote inotify-tools). Ele monitora o filesystem a nível de kernel e dispara assim que um arquivo muda.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce se muitos cambios de uma vez
  done
}

autosave &

Vamos destrinchar os flags do inotify, porque cada um importa:

  • -r → recursivo, monitora todas as subpastas
  • -e modify,create,delete,move → reage a esses 4 tipos de eventos
  • --exclude '...' → uma regex para ignorar certos arquivos

O --exclude é crucial. Olha o que ele ignora:

  • .git → obviamente, senão cada commit dispararia um autosave que dispararia um commit... loop infinito. Catástrofe.
  • .apt-cache e .cache → os caches, que mudam o tempo todo e não queremos spammar no git
  • host.conf e tmate.sock → os arquivos de sessão, que mudam sem parar
  • .gitignore, .txt.swp → arquivos temporários (os .swp são arquivos de edição do vim)

Sem esse exclude, você teria um autosave que se dispara em loop nas próprias alterações. O .git na lista é A linha que te impede de dar um tiro no pé.

Você modifica um arquivo? inotify detecta instantaneamente, commita, pusheia. Em menos de um segundo, sua alteração está no branch filesystem.

Você instala algo, escreve código, mexe numa configuração -- tudo é salvo em tempo real, automaticamente, sem você fazer nada.

Você tem literalmente um sistema de backup automático do disco inteiro. Louco.

O debounce: não spammar o git

O sleep 1 após cada save é um debounce.

Quando você salva um arquivo num editor, geralmente gera vários eventos de filesystem em rajada (criação de um arquivo temporário, rename, remoção do antigo...). Sem debounce, você dispararia 3-4 commits para um único save.

O sleep 1 diz: "espera um segundo depois de um save, o tempo da rajada se acalmar, antes de escutar de novo". Isso agrupa mudanças próximas num único commit. Esperto.

E um save periódico extra

Caso o inotify perca algo, também tem um save a cada 5 segundos:

periodic_save() {
  while true; do
    sync_from_remote   # pega possíveis mudanças remotas
    sleep 5
    commit_and_push
  done
}

periodic_save &

Cinto E suspensórios. Não queremos perder o estado do disco.


O detalhe esperto: um único commit

Se você commitar a cada mudança de arquivo, vai acumular milhares de commits. Em uma hora de sessão, seu histórico git explode. O repositório fica enorme. É nojento.

A solução é elegante: fazemos amend do commit existente em vez de criar um novo.

commit_and_push() {
  (
    flock -n 200 || return   # lock para dois saves não rodarem ao mesmo tempo

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # não mexe nos scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: sobrescreve o commit anterior
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend significa: "substitua o último commit por este".

Assim o branch filesystem tem SEMPRE um único commit. Não importa quantas vezes você salve. É apenas um snapshot do estado atual, force-pushado repetidamente.

O flock é um lock: como tem dois loops de save (inotify + periódico), precisamos evitar que eles executem git ao mesmo tempo e atrapalhem um ao outro. Apenas um processo git por vez.

Limpo.


O sync_from_remote: gerenciar várias sessões

Sabe, uma coisa que você não pensa no início: e se você executar DOIS runs ao mesmo tempo? Ou se uma sessão modificar o branch filesystem enquanto outra está rodando?

O script lida com isso usando sync_from_remote antes de cada commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

O --ff-only (fast-forward only) é importante: significa "merge SOMENTE se puder avançar limpo, sem criar commit de merge".

Se os dois branches divergiram (tipo, duas sessões modificaram coisas diferentes), o fast-forward falha silenciosamente (2>/dev/null || true) e mantemos o estado local. Não é um sistema de merge perfeito, mas evita corrupções no caso simples de apenas uma sessão rodando.

Sinceramente, não é para rodar 3 sessões em paralelo no mesmo repositório. Mas o código tenta não explodir se isso acontecer. É defesa.


O cache APT: instalar rápido

Tem um detalhe no workflow que parece inocente mas é bem pensado:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate e inotify-tools são instalados através de uma ação que faz cache dos pacotes APT.

Na primeira execução, baixa e instala. Nas execuções seguintes, é restaurado do cache do GitHub Actions -- mais rápido, sem precisar baixar de novo.

E lembra do git clean -fdx -e .apt-cache de antes? Está relacionado. A pasta .apt-cache é protegida da limpeza justamente para que os pacotes que você instala durante a sessão possam persistir minimamente.

Tudo se conecta. Pensei no ciclo de vida completo.


Os scripts escondidos em /tmp

Mais um detalhe sacana mas esperto. Bem no início do script:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Os scripts (update_readme.py, etc.) são copiados para /tmp ANTES de mexer no branch filesystem.

Por quê? Porque quando você faz git reset --hard para o branch filesystem (que está vazio no início, ou contém seu disco), os arquivos .github/scripts do repositório fonte desaparecem do workspace.

Mas o script ainda precisa deles durante a sessão (para atualizar o README a cada reinício do tmate). Então ele os esconde em /tmp, fora do alcance do git:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Se você não pensa nisso, passa 30 minutos tentando entender por que seu script sumiu. Eu pensei.


O shell personalizado

Pequeno conforto: a sessão te dá um shell configurado, não um bash pelado.

O prestart.sh copia um .bashrc customizado:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

E esse .bashrc contém um prompt colorido, aliases (ll, lla, rm -i), e principalmente um override do exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D faz o mesmo que exit
bind -x '"\C-d": "exit"'

Quando você digita exit (ou Ctrl+D), mata limpo os processos tmate antes de fechar. Isso evita deixar sessões tmate zumbis.

Também tem uma função tmate-detach se você quiser desconectar SEM matar a sessão (para reconectar depois). Detalhe de conforto, mas mostra o nível de cuidado.


O tmate que reinicia sozinho

Pequeno conforto: se você digitar exit no shell, normalmente a sessão tmate morre e você é desconectado para sempre.

Só que aqui, o tmate está num loop while true:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

Você exit? A sessão reinicia sozinha. Você se reconecta com o mesmo link.

É bobo, mas torna a coisa utilizável.


A reconexão em um comando

Como você se reconecta depois de uma desconexão, sem ter que fuçar nos logs do run toda vez?

O endereço SSH do tmate é escrito num arquivo host.conf, commitado no branch filesystem:

printf '%s' "${tmate_ssh#ssh }" > host.conf

E como esse arquivo está no git, você pode recuperá-lo pela API do GitHub com um único comando:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Você executa isso, ele busca o endereço SSH atual no repositório e conecta. Mesmo que o endereço tenha mudado entre sessões.


O fluxo completo

Recapitulando:

  1. Você dispara o workflow (push ou botão manual)
  2. GitHub te dá uma VM Ubuntu
  3. O script restaura o disco a partir do branch "filesystem"
  4. inotify começa a monitorar todas as mudanças
  5. periodic_save commita a cada 5s como backup
  6. tmate inicia → gera os links SSH/web
  7. Os links são escritos no README + host.conf
  8. Você conecta com ssh ou o terminal web
  9. Você faz o que quiser -- cada mudança de arquivo = autosave
  10. 6h depois, GitHub mata a VM
  11. Seu disco está intacto no branch "filesystem"
  12. Você reinicia o workflow → volta ao passo 3, tudo ainda está lá

Um VPS gratuito com disco persistente. Só com git e GitHub Actions.


Beleza, preciso ser honesto: os limites

É um hack, não um VPS de verdade. Então:

  • 6h no máximo por execução. Precisa reiniciar o workflow regularmente. Sem uptime infinito.
  • Não é para produção. Você não vai hospedar seu site nisso. É para explorar, dev, debug, testar algo num Linux descartável mas recuperável.
  • GitHub vê tudo. São máquinas deles. Não coloque nada sensível.
  • Mantenha o repositório privado. Você está expondo um shell SSH. Um repositório público = qualquer um pode potencialmente se conectar. Má ideia.
  • Está no limite dos termos de uso. GitHub Actions é feito para CI/CD, não para VPS gratuito. Então use com moderação, para coisas legítimas, sem abusar.

O verdadeiro calcanhar de Aquiles: git odeia arquivos grandes

Git é feito para texto, não para um filesystem.

O disco persistente vive num branch git. Então tudo que você salva passa pelo git. E git:

  • lida mal com arquivos binários grandes (uma imagem Docker de 2 GB no git? esquece)
  • tem um limite de 100 MB por arquivo no GitHub (hard limit, não passa disso)
  • recomenda ficar abaixo de ~5 GB por repositório

Então se você npm install um projeto com 500 MB de node_modules, ou se builda algo que gera binários pesados, o push para filesystem vai ou arrastar demais, ou simplesmente falhar.

O git commit --amend ajuda (um único commit, sem histórico que incha), mas não muda o fato de que um arquivo de 200 MB nunca vai passar.

Resumindo: funciona super bem para código, configurações, arquivos pequenos. Não funciona para armazenar dados grandes ou artefatos binários. Precisa ter isso em mente sobre o que você faz na sua sessão.

Não é um snapshot completo do sistema

Outra nuance importante: o branch filesystem salva o workspace (a pasta do repositório), não o sistema inteiro.

Se você fizer apt install htop, o binário vai para /usr/bin/htop, que está FORA do workspace. Então NÃO será salvo. Na próxima execução, precisa reinstalar.

É por isso que temos o cache APT e o prestart.sh: para re-preparar o ambiente do sistema a cada inicialização, já que só o workspace persiste.

Se você quer que suas instalações sobrevivam, precisa colocá-las no workspace (tipo, instalar numa pasta local em vez de no sistema). É uma ginástica para se acostumar.


VPS gratuito vs VPS de verdade: o duelo

repo-to-vps VPS de verdade (5€/mês)
Preço 0€ ~5-10€/mês
Uptime 6h, precisa reiniciar 24/7
Disco branch git, arquivos pequenos SSD de verdade, vários GB
RAM ~7 GB (generoso!) 1-2 GB geralmente
CPU 2-4 núcleos decentes 1-2 vCPU
Setup clonar um template configuração manual
Persistência só o workspace sistema completo
Legitimidade limite dos ToS 100% limpo

O engraçado é que em especificações brutas (RAM, CPU), o runner do GitHub é geralmente MELHOR que um VPS de 5€. Mas o uptime de 6h e a persistência limitada ao workspace é o que faz dele um brinquedo de hacker, não um servidor de verdade.

Para aprender, testar, debugar algo Linux rapidamente num ambiente recuperável? Perfeito. Para hospedar qualquer coisa séria? Pega um VPS de verdade.

Mas para um ambiente Linux temporário que você pode restaurar à vontade? É simplesmente genial.


O padrão por trás de tudo isso

Se você olhar de longe, repo-to-vps e o bot de email (meu outro artigo) se baseiam na mesma ideia:

Git não é só um gerenciador de versões. É um sistema de armazenamento persistente, gratuito, versionado, acessível via API.

Assim que você tem um sistema stateless (GitHub Actions, um Worker, uma função serverless) e quer manter estado entre duas execuções, git pode servir como "disco".

  • O bot de email armazena um lastId numa tag git.
  • repo-to-vps armazena um filesystem inteiro num branch git.

Mesmo padrão, duas escalas. Um valor de um lado, um disco do outro.

E o git commit --amend + force-push é a técnica comum: você mantém um único commit que representa o estado atual, sobrescrito a cada atualização.

Não foi feito para isso. Mas funciona. E é gratuito.


As 3 coisas para guardar:

  1. Um branch git = um disco rígido persistente -- Armazene seu filesystem num branch dedicado, restaure na inicialização, e você tem um estado que sobrevive a máquinas descartáveis.

  2. inotify + git = autosave em tempo real -- inotifywait monitora as mudanças a nível de kernel e push para git instantaneamente. Com git commit --amend para manter um único commit limpo.

  3. tmate transforma um runner em VPS -- SSH ao vivo numa máquina do GitHub Actions, com reinício automático e reconexão em um comando via API do GitHub.

Git como disco rígido, segundo episódio. Acho que vou acabar armazenando tudo em branches git xD

Repo to VPS : mengubah GitHub Actions menjadi VPS gratis dengan penyimpanan persisten

Cara mengubah runner GitHub Actions menjadi VPS permanen dengan git sebagai penyimpanan persisten -- tmate, inotify dan commit --amend.

GitHub kasih kamu VPS gratis selama 6 jam. Aku nemu cara bikinnya permanen.

GitHub Actions kasih kamu mesin Linux gratis.

Iya, beneran server Ubuntu. 2 core, 7 GB RAM, 14 GB disk. Gratis. Selama 6 jam per run.

Satu-satunya "masalah" : di akhir run, semuanya dihapus. Mesinnya disposable. Kamu install berbagai hal, ngoding, konfigurasi... dan poof, di akhir semuanya hilang. Seperti tidak pernah terjadi.

Kecuali.

Kecuali kamu pakai git sebagai hard disk.

Dan tiba-tiba, kamu punya VPS gratis dengan disk persisten yang bertahan dari run ke run. Kamu reconnect, semuanya masih ada. Kamu lanjutin dari tempat kamu berhenti.

Ini benar-benar gila. Biar aku jelasin xD


Konteks : runner GitHub Actions

Saat kamu menjalankan workflow GitHub Actions, GitHub kasih kamu VM.

Ini dibuat untuk build kode, jalankan test, deploy. Workflow berjalan, melakukan tugasnya, dan mesinnya dihancurkan.

Tapi tidak ada yang melarang kamu melakukan hal lain dengan VM ini. Misalnya, buka shell SSH di atasnya dan pakai sebagai server.

Masalahnya, mesin-mesin ini stateless dan sementara :

  • Sementara : maksimal 6 jam per run (timeout-minutes: 360, batas dari GitHub)
  • Stateless : semua dihapus di akhir

Jadi untuk menjadikannya VPS yang bisa dipakai, harus selesaikan dua masalah :

  1. Bagaimana cara terhubung secara real-time ?
  2. Bagaimana cara menyimpan disk antar run ?

Nah ini jadi hack yang kotor.


Masalah 1 : SSH live dengan tmate

tmate adalah fork dari tmux yang membuat session SSH yang bisa dibagikan.

Kamu jalankan di sebuah mesin, dia menghasilkan dua link :

Kamu connect dengan salah satu link ini, dan boom, kamu masuk ke shell di mesin itu. Secara real-time.

Workflow-nya menjalankan tmate :

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# ambil link koneksi
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Dan link-link ini ditulis langsung ke README repo oleh script Python. Kamu buka repo, kamu lihat link koneksi, kamu klik. Selamat datang di VPS kamu.

Masalah pertama beres. Tapi yang kedua yang benar-benar gila.


Masalah 2 : git sebagai hard disk

Ini dia hal gila.

Mesinnya dihapus setiap run. Jadi kita simpan sistem file di branch git khusus, bernama filesystem.

Saat startup, script me-restore state dari branch ini :

filesystem_branch="filesystem"

# ambil branch filesystem dari remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restore workspace dari branch ini
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

Branch filesystem ADALAH hard disk kamu. File kamu, installasi kamu, konfigurasi kamu -- semuanya ada di dalamnya.

Kamu lihat kan? Mesinnya disposable, tapi disk-nya hidup di git. Kamu jalankan ulang workflow, disk-nya di-restore, kamu lanjut tepat dari tempat kamu berhenti.

Ini seperti VPS yang hibernate. Cuma hibernasi-nya berupa repo git xD

Pertama kali jalan : buat disk kosong

Pertama kali run, branch filesystem belum ada. Harus dibuat. Dan ini tidak sepele :

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan adalah kuncinya. Branch orphan adalah branch tanpa history sama sekali -- seperti kamu mulai dari repo kosong.

Kenapa orphan? Karena kamu TIDAK ingin disk persisten kamu membawa seluruh history kode sumber. Disk adalah entitas terpisah, punya hidup sendiri. Dia mulai kosong.

Dan git ls-remote --exit-code di awal, itu cuma pengecekan bersih : "apakah branch sudah ada di remote?". Kalau iya, tidak dilakukan apa-apa. Kalau tidak, dibuat. Idempoten, seperti yang kita suka.

Git clean selektif : melindungi cache

Baris ini layak untuk kita bahas :

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx itu MEMBUANG SEMUA yang tidak di-track oleh git. Biasanya ini keras -- membersihkan workspace habis-habisan.

Tapi -e (exclude) melindungi beberapa hal :

  • .apt-cache → cache paket APT (akan dibahas lagi, ini pintar)
  • .cache → cache generik
  • host.conf → alamat SSH session
  • tmate.sock → socket session tmate yang sedang berjalan

Kalau kamu bersihkan file-file itu, kamu akan merusak session aktif atau kehilangan cache. Jadi mereka disisihkan saat reset.

Detail kecil yang kelihatannya sepele, tapi tanpanya semuanya berantakan.


Autosave : inotify yang memantau semuanya

Nah, tapi bagaimana file-file itu masuk ke branch filesystem?

Jawaban : sebuah watcher yang memantau SEMUA perubahan file dan commit/push secara otomatis.

Alat ajaibnya adalah inotifywait (dari paket inotify-tools). Dia memantau filesystem di level kernel dan terpicu saat file berubah.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce kalau banyak perubahan sekaligus
  done
}

autosave &

Mari kita bedah flag inotify, karena masing-masing penting :

  • -r → rekursif, memantau semua sub-direktori
  • -e modify,create,delete,move → bereaksi terhadap 4 tipe event ini
  • --exclude '...' → regex untuk mengabaikan file tertentu

--exclude sangat krusial. Lihat apa yang diabaikan :

  • .git → jelas, kalau tidak setiap commit akan memicu autosave yang memicu commit lagi... infinite loop. Bencana.
  • .apt-cache dan .cache → cache, yang sering berubah dan tidak ingin kita spam di git
  • host.conf dan tmate.sock → file session, yang terus berubah
  • .gitignore, .txt.swp → file sementara (.swp adalah file editing vim)

Tanpa exclude ini, autosave akan terpicu terus-menerus oleh perubahannya sendiri. .git dalam daftar, itu BARIS yang mencegah kamu menembak kaki sendiri.

Kamu modifikasi file? inotify mendeteksinya seketika, commit, push. Kurang dari satu detik, perubahanmu sudah ada di branch filesystem.

Kamu install sesuatu, nulis kode, menyentuh konfigurasi -- semua tersimpan real-time, otomatis, tanpa kamu melakukan apapun.

Kamu benar-benar punya sistem backup otomatis seluruh disk. Gila.

Debounce : jangan spam git

sleep 1 setelah setiap save adalah debounce.

Saat kamu menyimpan file di editor, seringkali menghasilkan beberapa event filesystem beruntun (pembuatan file temp, rename, penghapusan file lama...). Tanpa debounce, kamu akan memicu 3-4 commit untuk satu kali save.

sleep 1 bilang : "tunggu satu detik setelah save, biar rangkaian eventnya reda, sebelum mendengarkan lagi". Ini mengelompokkan perubahan yang berdekatan menjadi satu commit. Pintar.

Dan periodic save tambahan

Untuk jaga-jaga kalau inotify kelewatan sesuatu, ada juga save setiap 5 detik :

periodic_save() {
  while true; do
    sync_from_remote   # ambil perubahan remote yang mungkin ada
    sleep 5
    commit_and_push
  done
}

periodic_save &

Sabuk DAN tali pengaman. Kita benar-benar tidak mau kehilangan state disk.


Detail pintar : satu commit aja

Kalau kamu commit setiap perubahan file, kamu akan mengumpulkan ribuan commit. Dalam satu jam session, history git kamu meledak. Repo jadi besar. Ini menjijikkan.

Solusinya elegan : kita amend commit yang ada daripada membuat yang baru.

commit_and_push() {
  (
    flock -n 200 || return   # lock biar dua save tidak berjalan bersamaan

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # jangan sentuh scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : timpa commit sebelumnya
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend artinya : "ganti commit terakhir dengan yang ini".

Jadi branch filesystem SELALU hanya punya satu commit. Tidak peduli berapa kali kamu save. Hanya snapshot dari state terkini, di-force-push berulang kali.

flock adalah kunci : karena ada dua loop save (inotify + periodic), harus dicegah agar mereka tidak menjalankan git bersamaan dan saling menginjak. Hanya satu proses git dalam satu waktu.

Bersih.


Sync_from_remote : menangani beberapa session

Nah, sesuatu yang tidak terpikirkan di awal : gimana kalau kamu menjalankan DUA run bersamaan? Atau kalau satu session memodifikasi branch filesystem sementara yang lain berjalan?

Script menangani ini dengan sync_from_remote sebelum setiap commit :

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only) itu penting : artinya "merge HANYA kalau bisa maju dengan bersih, tanpa membuat commit merge".

Kalau kedua branch divergen (misalnya, dua session memodifikasi hal berbeda), fast-forward gagal secara diam-diam (2>/dev/null || true) dan state lokal dipertahankan. Ini bukan sistem merge yang sempurna, tapi menghindari korupsi dalam kasus sederhana di mana hanya satu session yang berjalan.

Sejujurnya, jangan jalankan 3 session paralel di repo yang sama. Tapi kode ini tetap berusaha untuk tidak meledak kalau itu terjadi. Ini pertahanan.


Cache APT : install cepat

Ada detail di workflow yang kelihatan sepele tapi dipikirkan dengan baik :

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate dan inotify-tools diinstall melalui action yang me-cache paket APT.

Di run pertama, download dan install. Di run berikutnya, di-restore dari cache GitHub Actions -- lebih cepat, tidak perlu download ulang.

Dan ingat git clean -fdx -e .apt-cache dari tadi? Itu terkait. Folder .apt-cache dilindungi dari pembersihan justru agar paket yang kamu install selama session bisa bertahan minimal.

Semuanya terkait. Aku sudah memikirkan siklus hidup lengkap.


Script yang disembunyikan di /tmp

Satu lagi detail licik tapi pintar. Di awal script :

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Script (update_readme.py, dll.) dicopy ke /tmp SEBELUM menyentuh branch filesystem.

Kenapa? Karena saat kamu melakukan git reset --hard ke branch filesystem (yang kosong di awal, atau berisi disk kamu), file .github/scripts dari repo sumber akan hilang dari workspace.

Tapi script masih diperlukan selama session (untuk update README setiap kali tmate restart). Jadi dia disembunyikan di /tmp, di luar jangkauan git :

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Kalau tidak dipikirkan, kamu akan pusing 30 menit mencari tahu kenapa script kamu hilang. Aku sudah pikirkan itu.


Shell custom

Sedikit kenyamanan : session memberi kamu shell yang sudah dikonfigurasi, bukan bash kosong.

prestart.sh menyalin .bashrc custom :

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

Dan .bashrc ini berisi prompt berwarna, alias (ll, lla, rm -i), dan yang terpenting override exit :

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D sama seperti exit
bind -x '"\C-d": "exit"'

Saat kamu ketik exit (atau Ctrl+D), ini mematikan proses tmate dengan bersih sebelum menutup. Ini mencegah session tmate zombie.

Ada juga fungsi tmate-detach kalau kamu ingin disconnect TANPA mematikan session (untuk reconnect nanti). Detail kenyamanan, tapi menunjukkan tingkat perhatian.


Tmate yang restart sendiri

Sedikit kenyamanan : kalau kamu ketik exit di shell, biasanya session tmate mati dan kamu disconnect untuk selamanya.

Tapi di sini, tmate ada di dalam loop while true :

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

Kamu exit ? Session restart otomatis. Kamu reconnect dengan link yang sama.

Ini konyol, tapi membuatnya bisa dipakai.


Reconnect dalam satu perintah

Bagaimana cara reconnect setelah disconnect, tanpa harus mencari-cari di log run setiap kali?

Alamat SSH tmate ditulis di file host.conf, di-commit ke branch filesystem :

printf '%s' "${tmate_ssh#ssh }" > host.conf

Dan karena file ini ada di git, kamu bisa ambil melalui API GitHub dengan satu perintah :

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Kamu jalankan ini, dia akan mengambil alamat SSH terkini dari repo, dan connect. Bahkan jika alamatnya berubah antar session.


Flow lengkap

Mari rekap :

  1. Kamu trigger workflow (push atau tombol manual)
  2. GitHub kasih kamu VM Ubuntu
  3. Script me-restore disk dari branch "filesystem"
  4. inotify mulai memantau semua perubahan
  5. periodic_save commit setiap 5 detik sebagai backup
  6. tmate mulai → menghasilkan link SSH/web
  7. Link ditulis ke README + host.conf
  8. Kamu connect dengan ssh atau terminal web
  9. Kamu lakukan apapun yang kamu mau -- setiap perubahan file = autosave
  10. 6 jam kemudian, GitHub mematikan VM
  11. Disk kamu utuh di branch "filesystem"
  12. Kamu jalankan ulang workflow → kembali ke langkah 3, semuanya masih ada

VPS gratis dengan disk persisten. Hanya dengan git dan GitHub Actions.


Baiklah, harus jujur : keterbatasannya

Ini hack, bukan VPS beneran. Jadi :

  • Maksimal 6 jam per run. Harus jalankan ulang workflow secara teratur. Tidak ada uptime tanpa batas.
  • Bukan untuk production. Kamu tidak akan host situs di sini. Ini untuk eksplorasi, dev, debug, testing sesuatu di Linux disposable tapi bisa dipulihkan.
  • GitHub melihat semuanya. Ini mesin mereka. Jangan simpan data sensitif.
  • Jaga repo tetap private. Kamu mengekspos shell SSH. Repo public = siapapun berpotensi connect. Ide buruk.
  • Ini di batas ketentuan penggunaan. GitHub Actions dibuat untuk CI/CD, bukan untuk VPS gratis. Jadi gunakan secukupnya, untuk hal yang sah, tanpa menyalahgunakan.

Achilles heel yang sesungguhnya : git benci file besar

Git dibuat untuk teks, bukan untuk filesystem.

Disk persisten hidup di branch git. Jadi semua yang kamu simpan melewati git. Dan git :

  • tidak cocok untuk file biner besar (image Docker 2 GB di git? lupakan)
  • punya batas 100 MB per file di GitHub (hard limit, tidak bisa push lebih)
  • merekomendasikan tetap di bawah ~5 GB per repo

Jadi kalau kamu npm install project dengan 500 MB node_modules, atau build sesuatu yang menghasilkan biner berat, push ke filesystem akan sangat lambat atau bahkan gagal total.

git commit --amend membantu (satu commit, tidak ada history yang membengkak), tapi tidak mengubah fakta bahwa file 200 MB tidak akan pernah berhasil.

Intinya : ini bekerja sangat baik untuk kode, konfigurasi, file kecil. Tidak bekerja untuk menyimpan data besar atau artefak biner. Harus diingat saat melakukan sesuatu di session.

Ini bukan snapshot sistem lengkap

Nuansa penting lainnya : branch filesystem menyimpan workspace (folder repo), bukan seluruh sistem.

Kalau kamu apt install htop, biner-nya masuk ke /usr/bin/htop, yang DI LUAR workspace. Jadi tidak akan DISIMPAN. Di run berikutnya, harus install ulang.

Itulah kenapa ada cache APT dan prestart.sh : untuk menyiapkan ulang environment sistem setiap startup, karena hanya workspace yang bertahan.

Kalau kamu ingin installasi kamu bertahan, harus taruh di workspace (misalnya, install di folder lokal daripada di sistem). Ini gaya olahraga yang harus dibiasakan.


VPS gratis vs VPS beneran : perbandingan

repo-to-vps VPS Beneran (5€/bulan)
Harga 0€ ~5-10€/bulan
Uptime 6 jam, harus restart 24/7
Disk branch git, file kecil SSD beneran, beberapa GB
RAM ~7 GB (besar!) 1-2 GB biasanya
CPU 2-4 core lumayan 1-2 vCPU
Setup clone template konfigurasi manual
Persistensi workspace saja sistem lengkap
Legitimasi batas Syarat Ketentuan 100% bersih

Lucunya, dari segi spek mentah (RAM, CPU), runner GitHub seringkali LEBIH BAIK daripada VPS 5€. Tapi uptime 6 jam dan persistensi terbatas di workspace, inilah yang menjadikannya mainan hacker, bukan server beneran.

Untuk belajar, testing, debugging Linux cepat dalam environment yang bisa dipulihkan? Sempurna. Untuk host sesuatu yang serius? Ambil VPS beneran.

Tapi untuk environment Linux sementara yang bisa kamu restore kapan saja? Ini luar biasa.


Pattern di balik semua ini

Kalau kamu lihat dari jauh, repo-to-vps dan bot email (artikelku yang lain) didasari ide yang sama :

Git bukan hanya version control. Ini adalah sistem penyimpanan persisten, gratis, versioned, bisa diakses via API.

Begitu kamu punya sistem stateless (GitHub Actions, Worker, fungsi serverless) dan kamu ingin menjaga state antar eksekusi, git bisa berfungsi sebagai "disk".

  • Bot email menyimpan lastId di git tag.
  • repo-to-vps menyimpan seluruh filesystem di branch git.

Pattern sama, dua skala. Satu nilai di satu sisi, satu disk di sisi lain.

Dan git commit --amend + force-push adalah teknik umumnya : kamu menyimpan satu commit yang merepresentasikan state terkini, ditimpa setiap update.

Ini tidak dirancang untuk itu. Tapi berhasil. Dan gratis.


3 hal yang perlu diingat :

  1. Branch git = hard disk persisten -- Simpan filesystem di branch khusus, restore saat startup, dan kamu punya state yang bertahan dari mesin disposable.

  2. inotify + git = autosave real-time -- inotifywait memantau perubahan di level kernel dan push ke git secara instan. Dengan git commit --amend untuk menjaga satu commit yang bersih.

  3. tmate mengubah runner menjadi VPS -- SSH live di mesin GitHub Actions, dengan auto-restart dan reconnect satu perintah via API GitHub.

Git sebagai hard disk, episode kedua. Kayaknya aku akan berakhir menyimpan semuanya di branch git xD

Repo to VPS : GitHub Actions को मुफ़्त VPS में बदलें स्थायी स्टोरेज के साथ

GitHub Actions रनर को स्थायी स्टोरेज के साथ एक स्थायी VPS में कैसे बदलें -- tmate, inotify और commit --amend.

GitHub आपको 6 घंटे के लिए मुफ़्त VPS देता है। मैंने इसे स्थायी बनाने का तरीका ढूंढ लिया।

GitHub Actions आपको मुफ़्त Linux मशीनें देता है।

हाँ, असली Ubuntu सर्वर। 2 कोर, 7 GB RAM, 14 GB डिस्क। मुफ़्त। 6 घंटे प्रति run।

एकमात्र "समस्या" : run के अंत में, सब कुछ मिट जाता है। मशीन डिस्पोज़ेबल है। तुम चीज़ें इंस्टॉल करते हो, कोड लिखते हो, कॉन्फ़िगर करते हो... और फिर, अंत में सब गायब। जैसे कुछ किया ही नहीं।

सिवाय इसके।

सिवाय इसके कि तुम git को हार्ड ड्राइव की तरह इस्तेमाल करो।

और फिर, अचानक, तुम्हारे पास एक मुफ़्त VPS है जिसकी डिस्क runs के बाद भी बची रहती है। तुम फिर से जुड़ते हो, सब कुछ वहीं है। तुम वहीं से शुरू करते हो जहाँ छोड़ा था।

यह पूरी तरह से टूटा हुआ है। मुझे समझाने दो xD


संदर्भ : GitHub Actions रनर

जब तुम GitHub Actions वर्कफ़्लो चलाते हो, GitHub तुम्हें एक VM देता है।

यह तुम्हारा कोड build करने, टेस्ट चलाने, deploy करने के लिए है। वर्कफ़्लो चलता है, अपना काम करता है, और मशीन नष्ट हो जाती है।

लेकिन कोई तुम्हें इस VM के साथ कुछ और करने से नहीं रोकता। जैसे, उस पर SSH शेल खोलना और इसे सर्वर की तरह इस्तेमाल करना।

बात यह है कि ये मशीनें stateless और अस्थायी हैं :

  • अस्थायी : अधिकतम 6 घंटे प्रति run (timeout-minutes: 360, GitHub की सीमा)
  • Stateless : अंत में सब मिट जाता है

तो इसे एक उपयोगी VPS बनाने के लिए, दो समस्याएँ हल करनी होंगी :

  1. इससे रियल-टाइम में कैसे जुड़ें ?
  2. दो runs के बीच डिस्क को कैसे बचाए रखें ?

यहाँ से यह एक गंदा hack बन जाता है।


समस्या 1 : tmate के साथ लाइव SSH

tmate tmux का एक fork है जो एक share करने योग्य SSH सत्र बनाता है।

तुम इसे एक मशीन पर चलाते हो, यह दो लिंक जनरेट करता है :

  • एक SSH URL (ssh [email protected])
  • एक web URL (ब्राउज़र में टर्मिनल)

तुम इनमें से किसी एक लिंक से जुड़ते हो, और boom, तुम मशीन पर एक शेल में हो। रियल-टाइम में।

वर्कफ़्लो tmate लॉन्च करता है :

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# कनेक्शन लिंक प्राप्त करें
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

और ये लिंक एक Python स्क्रिप्ट द्वारा सीधे repo के README में लिख दिए जाते हैं। तुम अपना repo खोलते हो, कनेक्शन लिंक देखते हो, क्लिक करते हो। और तुम अपने VPS में हो।

पहली समस्या हल। लेकिन दूसरी ही असली पागलपन है।


समस्या 2 : git को हार्ड ड्राइव की तरह इस्तेमाल करना

यह रहा वो पागलपन भरा तरीका।

हर run पर मशीन मिट जाती है। इसलिए हम फ़ाइल सिस्टम को एक समर्पित git ब्रांच में स्टोर करते हैं, जिसे filesystem कहा जाता है।

शुरू होने पर, स्क्रिप्ट इस ब्रांच से स्थिति को रीस्टोर करती है :

filesystem_branch="filesystem"

# remote से filesystem ब्रांच प्राप्त करें
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# इस ब्रांच से workspace रीस्टोर करें
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

filesystem ब्रांच तुम्हारी हार्ड ड्राइव है। तुम्हारी फ़ाइलें, तुम्हारे इंस्टॉल, तुम्हारी कॉन्फ़िग -- सब कुछ इसमें है।

समझे ? मशीन डिस्पोज़ेबल है, लेकिन डिस्क git में रहती है। तुम वर्कफ़्लो फिर से चलाते हो, डिस्क रीस्टोर हो जाती है, तुम ठीक वहीं से शुरू करते हो जहाँ थे।

यह एक ऐसे VPS जैसा है जो hibernate करता है। बस फर्क इतना है कि hibernation एक git repo है xD

पहली बार : खाली डिस्क बनाना

पहले run पर, filesystem ब्रांच अभी मौजूद नहीं होती। इसे बनाना होता है। और यह आसान नहीं है :

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan यहाँ कुंजी है। एक orphan ब्रांच वह ब्रांच है जिसका कोई इतिहास नहीं होता -- जैसे तुम एक खाली repo से शुरू कर रहे हो।

Orphan क्यों ? क्योंकि तुम नहीं चाहते कि तुम्हारी स्थायी डिस्क तुम्हारे सोर्स कोड का पूरा इतिहास खींचे। डिस्क एक अलग चीज़ है, जिसका अपना जीवन है। यह खाली शुरू होती है।

और शुरू में git ls-remote --exit-code एक साफ़ जाँच है : "क्या यह ब्रांच पहले से remote पर मौजूद है ?" अगर हाँ, तो कुछ मत करो। अगर नहीं, तो इसे बनाओ। Idempotent, जैसा हमें पसंद है।

सिलेक्टिव git clean : कैश की सुरक्षा

इस लाइन पर रुकना ज़रूरी है :

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx वह सब कुछ हटा देता है जो git द्वारा track नहीं किया गया है। सामान्यतः यह हिंसक है -- यह workspace को पूरी तरह साफ़ करता है।

लेकिन -e (exclude) कुछ चीज़ों की रक्षा करता है :

  • .apt-cache → APT पैकेजों का कैश (इस पर फिर आएँगे, यह चतुर है)
  • .cache → सामान्य कैश
  • host.conf → सत्र का SSH पता
  • tmate.sock → चालू tmate सत्र का socket

अगर तुम इन फ़ाइलों को साफ़ करते, तो तुम सक्रिय सत्र को तोड़ देते या अपना कैश खो देते। इसलिए हम reset के दौरान इन्हें बचाते हैं।

पहली नज़र में एक छोटी सी बात, लेकिन इसके बिना सब कुछ टूट जाता है।


ऑटोसेव : inotify जो सब कुछ देखता है

अच्छा, लेकिन फ़ाइलें filesystem ब्रांच में कैसे पहुँचती हैं ?

जवाब : एक watcher जो फ़ाइलों में सभी बदलावों पर नज़र रखता है और ऑटोमैटिकली commit/push करता है।

जादुई टूल है inotifywait (पैकेज inotify-tools से)। यह kernel स्तर पर फ़ाइल सिस्टम पर नज़र रखता है और जैसे ही कोई फ़ाइल बदलती है, ट्रिगर होता है।

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce अगर एक साथ बहुत सारे बदलाव हों
  done
}

autosave &

inotify के flags को समझते हैं, क्योंकि हर एक मायने रखता है :

  • -r → रिकर्सिव, सभी सब-फ़ोल्डर पर नज़र रखता है
  • -e modify,create,delete,move → इन 4 प्रकार की घटनाओं पर प्रतिक्रिया करता है
  • --exclude '...' → कुछ फ़ाइलों को नज़रअंदाज़ करने के लिए regex

--exclude महत्वपूर्ण है। देखो यह क्या ignore करता है :

  • .git → बिल्कुल, नहीं तो हर commit एक autosave ट्रिगर करेगा जो एक और commit ट्रिगर करेगा... अनंत लूप। आपदा।
  • .apt-cache और .cache → कैश, जो हर समय बदलते हैं और जिन्हें हम git में spam नहीं करना चाहते
  • host.conf और tmate.sock → सत्र फ़ाइलें, जो लगातार बदलती रहती हैं
  • .gitignore, .txt.swp → अस्थायी फ़ाइलें (.swp vim की एडिटिंग फ़ाइलें हैं)

इस exclude के बिना, तुम्हारा autosave अपने ही बदलावों पर बार-बार ट्रिगर होता रहेगा। सूची में .git, वह लाइन है जो तुम्हें अपने पैर में गोली मारने से बचाती है।

तुम कोई फ़ाइल बदलते हो ? inotify इसे तुरंत डिटेक्ट करता है, commit करता है, push करता है। एक सेकंड से भी कम समय में, तुम्हारा बदलाव filesystem ब्रांच में आ जाता है।

तुम कुछ इंस्टॉल करते हो, कोड लिखते हो, कोई कॉन्फ़िग छूते हो -- सब कुछ रियल-टाइम में, ऑटोमैटिकली, बिना तुम्हारे कुछ किए सेव हो जाता है।

तुम्हारे पास सचमुच पूरी डिस्क का ऑटोमैटिक बैकअप सिस्टम है। पागलपन।

Debounce : git को spam मत करो

हर सेव के बाद sleep 1 एक debounce है।

जब तुम किसी एडिटर में फ़ाइल सेव करते हो, तो अक्सर यह एक साथ कई फ़ाइल सिस्टम इवेंट जनरेट करता है (टेम्प फ़ाइल बनाना, rename करना, पुरानी को हटाना...)। Debounce के बिना, एक ही सेव के लिए 3-4 commit ट्रिगर हो जाएँगे।

sleep 1 कहता है : "एक सेव के बाद एक सेकंड रुको, जब तक इवेंट्स की बाढ़ शांत न हो जाए, फिर से सुनना शुरू करो।" यह पास-पास के बदलावों को एक ही commit में समेट देता है। चतुर।

और एक अतिरिक्त आवधिक बैकअप

अगर inotify कुछ चूक जाए, तो हर 5 सेकंड में एक सेव भी है :

periodic_save() {
  while true; do
    sync_from_remote   # संभावित दूरस्थ बदलाव प्राप्त करें
    sleep 5
    commit_and_push
  done
}

periodic_save &

बेल्ट और सस्पेंडर्स। हम डिस्क की स्थिति खोना बिल्कुल नहीं चाहते।


चतुर विवरण : एक ही commit

अगर तुम हर फ़ाइल बदलाव पर commit करते हो, तो हज़ारों commit जमा हो जाएँगे। एक घंटे के सत्र में, तुम्हारा git इतिहास फट जाएगा। repo बहुत बड़ा हो जाएगा। गंदा।

समाधान सुरुचिपूर्ण है : हम नया commit बनाने के बजाय मौजूदा commit को amend करते हैं।

commit_and_push() {
  (
    flock -n 200 || return   # lock ताकि दो saves एक साथ न चलें

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # स्क्रिप्ट को मत छुओ

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : पिछले commit को मिटाओ
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend का मतलब है : "पिछले commit को इससे बदल दो।"

इस तरह filesystem ब्रांच में हमेशा एक ही commit होता है। चाहे तुम कितनी भी बार सेव करो। यह बस वर्तमान स्थिति का एक स्नैपशॉट है, बार-बार force-push किया गया।

flock एक लॉक है : चूँकि दो save लूप हैं (inotify + आवधिक), इसलिए बचना होगा कि वे एक ही समय में git लॉन्च करें और एक-दूसरे पर चढ़ जाएँ। एक बार में एक ही git process।

साफ़।


sync_from_remote : कई सत्रों को संभालना

अच्छा, एक चीज़ जिसके बारे में तुम शुरू में नहीं सोचते : क्या होगा अगर तुम एक साथ दो runs चलाओ ? या अगर एक सत्र filesystem ब्रांच को बदल दे जबकि दूसरा चल रहा हो ?

स्क्रिप्ट इसे हर commit से पहले sync_from_remote से संभालती है :

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only) महत्वपूर्ण है : इसका मतलब है "तभी merge करो जब हम साफ़-साफ़ आगे बढ़ सकें, बिना merge commit बनाए।"

अगर दोनों ब्रांचें अलग हो गई हैं (जैसे, दो सत्रों ने अलग-अलग चीज़ें बदली हैं), तो fast-forward चुपचाप विफल हो जाता है (2>/dev/null || true) और हम स्थानीय स्थिति रखते हैं। यह एक परफेक्ट merge सिस्टम नहीं है, लेकिन साधारण मामले (जहाँ एक ही सत्र चल रहा है) में यह भ्रष्टाचार से बचाता है।

ईमानदारी से, एक ही repo पर 3 समानांतर सत्र मत चलाओ। लेकिन कोड फिर भी कोशिश करता है कि अगर ऐसा होता है तो फटे नहीं। यह बचाव है।


APT कैश : तेज़ी से इंस्टॉल करें

वर्कफ़्लो में एक विवरण है जो दिखता तो कम है लेकिन अच्छी तरह सोचा गया है :

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate और inotify-tools एक ऐसी action से इंस्टॉल होते हैं जो APT पैकेजों को कैश करती है।

पहले run पर, यह डाउनलोड और इंस्टॉल करता है। बाद के runs पर, यह GitHub Actions कैश से रीस्टोर होता है -- तेज़, फिर से डाउनलोड करने की ज़रूरत नहीं।

और तुम्हें याद है पहले का git clean -fdx -e .apt-cache ? यह जुड़ा हुआ है। .apt-cache फ़ोल्डर को सफाई से बचाया जाता है ताकि जो पैकेज तुम अपने सत्र के दौरान इंस्टॉल करते हो वे कम से कम कुछ हद तक बने रह सकें।

सब कुछ आपस में जुड़ा है। मैंने पूरे जीवन चक्र के बारे में सोचा है।


/tmp में छिपाई गई स्क्रिप्ट

एक और पेचीदा लेकिन चतुर विवरण। स्क्रिप्ट की बिल्कुल शुरुआत में :

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

स्क्रिप्ट (update_readme.py, आदि) को filesystem ब्रांच को छूने से पहले /tmp में कॉपी किया जाता है।

क्यों ? क्योंकि जब तुम git reset --hard करते हो filesystem ब्रांच पर (जो शुरू में खाली है, या तुम्हारी डिस्क रखती है), सोर्स repo की .github/scripts फ़ाइलें workspace से गायब हो जाती हैं।

लेकिन स्क्रिप्ट को सत्र के दौरान उनकी ज़रूरत होती है (हर बार tmate फिर से शुरू होने पर README अपडेट करने के लिए)। इसलिए वह उन्हें /tmp में, git की पहुँच से बाहर छिपा देता है :

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

अगर तुम इसके बारे में नहीं सोचते, तो तुम 30 मिनट तक परेशान होते रहोगे कि तुम्हारी स्क्रिप्ट गायब क्यों हो गई। मैंने इसके बारे में सोचा।


कस्टम शेल

थोड़ी सुविधा : सत्र तुम्हें एक कॉन्फ़िगर्ड शेल देता है, न कि नंगा bash।

prestart.sh एक कस्टम .bashrc कॉपी करता है :

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

और इस .bashrc में एक रंगीन prompt, alias (ll, lla, rm -i), और सबसे महत्वपूर्ण, exit का ओवरराइड है :

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D exit के समान काम करता है
bind -x '"\C-d": "exit"'

जब तुम exit (या Ctrl+D) टाइप करते हो, यह बंद करने से पहले tmate प्रोसेस को साफ़-साफ़ kill करता है। यह ज़ोंबी tmate सत्रों को छोड़ने से बचाता है।

एक tmate-detach फ़ंक्शन भी है अगर तुम सत्र को मारे बिना डिस्कनेक्ट करना चाहते हो (बाद में फिर से जुड़ने के लिए)। सुविधा का विवरण, लेकिन यह देखभाल का स्तर दिखाता है।


tmate जो अपने आप फिर से शुरू हो जाता है

थोड़ी सुविधा : अगर तुम अपने शेल में exit टाइप करते हो, सामान्यतः tmate सत्र मर जाता है और तुम हमेशा के लिए डिस्कनेक्ट हो जाते हो।

लेकिन यहाँ, tmate एक while true लूप में है :

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

तुम exit करते हो ? सत्र अपने आप फिर से शुरू हो जाता है। तुम उसी लिंक से फिर से जुड़ते हो।

यह बेवकूफी है, लेकिन यह चीज़ को उपयोगी बनाता है।


एक कमांड में फिर से जुड़ना

डिस्कनेक्ट करने के बाद तुम फिर से कैसे जुड़ते हो, बिना हर बार run के लॉग में खोजबीन किए ?

tmate का SSH पता host.conf फ़ाइल में लिखा जाता है, जो filesystem ब्रांच में commit होता है :

printf '%s' "${tmate_ssh#ssh }" > host.conf

और चूँकि यह फ़ाइल git में है, तुम इसे GitHub API से एक ही कमांड से प्राप्त कर सकते हो :

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

तुम यह चलाते हो, यह repo से वर्तमान SSH पता लाता है, और तुमसे जुड़ता है। भले ही दो सत्रों के बीच पता बदल गया हो।


पूरा flow

संक्षेप में :

  1. तुम वर्कफ़्लो ट्रिगर करते हो (push या मैन्युअल बटन)
  2. GitHub तुम्हें एक Ubuntu VM देता है
  3. स्क्रिप्ट "filesystem" ब्रांच से डिस्क रीस्टोर करती है
  4. inotify सभी बदलावों पर नज़र रखना शुरू करता है
  5. periodic_save बैकअप में हर 5 सेकंड में commit करता है
  6. tmate शुरू होता है → SSH/web लिंक जनरेट करता है
  7. लिंक README + host.conf में लिखे जाते हैं
  8. तुम ssh या web टर्मिनल से जुड़ते हो
  9. तुम जो चाहो करो -- हर फ़ाइल बदलाव = autosave
  10. 6 घंटे बाद, GitHub VM को मार देता है
  11. तुम्हारी डिस्क "filesystem" ब्रांच में सुरक्षित है
  12. तुम वर्कफ़्लो फिर से चलाते हो → चरण 3 पर वापस, सब कुछ वहीं है

एक मुफ़्त VPS स्थायी डिस्क के साथ। सिर्फ git और GitHub Actions के साथ।


अच्छा, ईमानदार होना चाहिए : सीमाएँ

यह एक hack है, असली VPS नहीं। इसलिए :

  • अधिकतम 6 घंटे प्रति run। नियमित रूप से वर्कफ़्लो को फिर से चलाना होगा। कोई अनंत अपटाइम नहीं।
  • प्रोडक्शन के लिए नहीं। तुम अपनी साइट यहाँ होस्ट नहीं करोगे। यह एक्सप्लोर करने, डेवलप करने, डीबग करने, किसी चीज़ को डिस्पोज़ेबल लेकिन पुनर्प्राप्त करने योग्य Linux में टेस्ट करने के लिए है।
  • GitHub सब कुछ देखता है। ये उनकी मशीनें हैं। कोई संवेदनशील चीज़ मत रखो।
  • Repo को प्राइवेट रखो। तुम एक SSH शेल एक्सपोज़ कर रहे हो। पब्लिक repo = कोई भी संभावित रूप से जुड़ सकता है। बुरा विचार।
  • यह उपयोग की शर्तों की सीमा पर है। GitHub Actions CI/CD के लिए है, मुफ़्त VPS के लिए नहीं। इसलिए संयम से उपयोग करो, वैध काम के लिए, बिना दुरुपयोग के।

असली अकिलीज़ हील : git बड़ी फ़ाइलों से नफरत करता है

git टेक्स्ट के लिए है, फ़ाइल सिस्टम के लिए नहीं।

स्थायी डिस्क एक git ब्रांच में रहती है। इसलिए तुम जो कुछ भी सेव करते हो वह git से होकर जाता है। और git :

  • बड़ी बाइनरी फ़ाइलों को खराब तरीके से संभालता है (2 GB की Docker इमेज git में ? भूल जाओ)
  • GitHub पर प्रति फ़ाइल 100 MB की सीमा है (hard limit, इससे ऊपर push नहीं होगा)
  • प्रति repo ~5 GB से नीचे रहने की सलाह देता है

इसलिए अगर तुम 500 MB node_modules वाले प्रोजेक्ट पर npm install करते हो, या कोई ऐसी चीज़ build करते हो जो भारी बाइनरी थूकती है, तो filesystem पर push या तो बहुत धीमा होगा या पूरी तरह विफल हो जाएगा।

git commit --amend मदद करता है (एक ही commit, कोई फूलता इतिहास नहीं), लेकिन यह इस तथ्य को नहीं बदलता कि 200 MB की फ़ाइल कभी पास नहीं होगी।

संक्षेप में : यह कोड, कॉन्फ़िग, छोटी फ़ाइलों के लिए बहुत अच्छा काम करता है। बड़ा डेटा या बाइनरी आर्टिफ़ैक्ट स्टोर करने के लिए काम नहीं करता। यह ध्यान में रखना होगा कि तुम अपने सत्र में क्या करते हो।

यह पूरा सिस्टम स्नैपशॉट नहीं है

एक और महत्वपूर्ण बारीकी : filesystem ब्रांच workspace (repo फ़ोल्डर) को सेव करती है, पूरे सिस्टम को नहीं।

अगर तुम apt install htop करते हो, बाइनरी /usr/bin/htop में जाता है, जो workspace के बाहर है। इसलिए यह सेव नहीं होगा। अगले run पर, इसे फिर से इंस्टॉल करना होगा।

इसीलिए हमारे पास APT कैश और prestart.sh है : हर शुरूआत पर सिस्टम वातावरण को फिर से तैयार करने के लिए, क्योंकि केवल workspace ही बचता है।

अगर तुम चाहते हो कि तुम्हारे इंस्टॉल बचे रहें, तो उन्हें workspace में रखना होगा (जैसे, सिस्टम के बजाय स्थानीय फ़ोल्डर में इंस्टॉल करना)। यह एक आदत है जो डालनी होगी।


मुफ़्त VPS बनाम असली VPS : तुलना

repo-to-vps असली VPS (€5/महीना)
कीमत €0 ~₹450-900/महीना
Uptime 6 घंटे, फिर से चलाना होगा 24/7
डिस्क git ब्रांच, छोटी फ़ाइलें असली SSD, कई GB
RAM ~7 GB (उदार !) अक्सर 1-2 GB
CPU 2-4 अच्छे कोर 1-2 vCPU
Setup टेम्पलेट क्लोन करें मैन्युअल कॉन्फ़िग
स्थायित्व केवल workspace पूरा सिस्टम
वैधता CGU की सीमा पर 100% साफ़

मज़ेदार बात यह है कि कच्चे specs (RAM, CPU) के मामले में, GitHub रनर अक्सर €5 वाले VPS से बेहतर होता है। लेकिन 6 घंटे का अपटाइम और workspace तक सीमित स्थायित्व, यही इसे एक हैकर का खिलौना बनाता है, न कि असली सर्वर।

सीखने, परीक्षण करने, किसी Linux चीज़ को जल्दी से एक पुनर्प्राप्त करने योग्य वातावरण में डीबग करने के लिए ? बिल्कुल सही। कोई भी गंभीर चीज़ होस्ट करने के लिए ? असली VPS लो।

लेकिन एक अस्थायी Linux वातावरण के लिए जिसे तुम जब चाहो रीस्टोर कर सकते हो ? यह बस शानदार है।


इस सबके पीछे का पैटर्न

अगर तुम पीछे हटकर देखो, repo-to-vps और ईमेल बॉट (मेरा दूसरा लेख) एक ही विचार पर आधारित हैं :

Git सिर्फ एक वर्जन कंट्रोल सिस्टम नहीं है। यह एक स्थायी, मुफ़्त, वर्जन्ड स्टोरेज सिस्टम है जो API के माध्यम से सुलभ है।

जैसे ही तुम्हारे पास कोई stateless सिस्टम (GitHub Actions, Worker, serverless फ़ंक्शन) हो और तुम दो निष्पादनों के बीच स्थिति बनाए रखना चाहते हो, git "डिस्क" का काम कर सकता है।

  • ईमेल बॉट एक git tag में lastId स्टोर करता है।
  • repo-to-vps एक git ब्रांच में पूरा फ़ाइल सिस्टम स्टोर करता है।

एक ही पैटर्न, दो पैमाने। एक तरफ एक वैल्यू, दूसरी तरफ एक डिस्क।

और git commit --amend + force-push सामान्य तकनीक है : तुम एक ही commit रखते हो जो वर्तमान स्थिति का प्रतिनिधित्व करता है, हर अपडेट पर मिटा दिया जाता है।

यह इसके लिए नहीं बना है। लेकिन यह काम करता है। और यह मुफ़्त है।


याद रखने के लिए 3 बातें :

  1. एक git ब्रांच = एक स्थायी हार्ड ड्राइव -- अपने फ़ाइल सिस्टम को एक समर्पित ब्रांच में स्टोर करो, शुरू होने पर रीस्टोर करो, और तुम्हारे पास एक ऐसी स्थिति होगी जो डिस्पोज़ेबल मशीनों से बची रहती है।

  2. inotify + git = रियल-टाइम ऑटोसेव -- inotifywait kernel स्तर पर बदलावों पर नज़र रखता है और तुरंत git में push करता है। git commit --amend के साथ एक ही साफ़ commit रखने के लिए।

  3. tmate एक रनर को VPS में बदल देता है -- GitHub Actions मशीन पर लाइव SSH, ऑटोमैटिक रीस्टार्ट और GitHub API के माध्यम से एक कमांड में फिर से जुड़ने की सुविधा के साथ।

git को हार्ड ड्राइव की तरह, दूसरा एपिसोड। लगता है मैं अंततः सब कुछ git ब्रांचों में स्टोर करने लगूँगा xD

Repo to VPS : تحويل GitHub Actions إلى VPS مجاني مع تخزين دائم

كيفية تحويل مشغل GitHub Actions إلى VPS دائم باستخدام git كتخزين دائم -- tmate و inotify و commit --amend.

GitHub يعطيك VPS مجاني لمدة 6 ساعات. لقد وجدت كيف أجعله دائماً.

GitHub Actions يعطيك أجهزة Linux مجانية.

أعني، خوادم Ubuntu حقيقية. 2 نواة، 7 جيجا رام، 14 جيجا قرص. مجاني. لمدة 6 ساعات لكل تشغيلة.

"المشكلة" الوحيدة: في نهاية التشغيلة، يُمسح كل شيء. الجهاز قابل للرمي. تثبّت أشياء، تكتب كوداً، تهيئ... وفجأة، في النهاية يختفي كل شيء. وكأنك لم تفعل شيئاً.

إلا إذا.

إلا إذا استخدمت git كقرص صلب.

وهنا، فجأة، تحصل على VPS مجاني بقرص دائم ينجو من التشغيلات. تتصل مرة أخرى، كل شيء لا يزال موجوداً. تستأنف من حيث توقفت.

هذا مكسور تماماً. دعني أشرح لك xD


السياق: مشغلات GitHub Actions

عندما تشغل سير عمل GitHub Actions، يعطيك GitHub جهاز VM.

هذا مصمم لبناء كودك، تشغيل اختباراتك، النشر. سير العمل يعمل، يؤدي مهمته، ثم يُدمر الجهاز.

لكن لا شيء يمنعك من فعل شيء آخر بهذا الـ VM. مثلاً، فتح شيل SSH عليه واستخدامه كخادم.

الفكرة هي أن هذه الأجهزة عديمة الحالة و مؤقتة:

  • مؤقتة: 6 ساعات كحد أقصى لكل تشغيلة (timeout-minutes: 360، سقف GitHub)
  • عديمة الحالة: كل شيء يُمحى في النهاية

إذاً لجعلها VPS قابل للاستخدام، يجب حل مشكلتين:

  1. كيف نتصل بها في الوقت الفعلي؟
  2. كيف نحتفظ بالقرص بين تشغيلتين؟

هنا يصبح الأمر اختراقاً قذراً.


المشكلة 1: SSH المباشر مع tmate

tmate هو fork من tmux ينشئ جلسة SSH قابلة للمشاركة.

تشغّله على جهاز، فيولّد لك رابطين:

تتصل بأحد هذين الرابطين، وفجأة، أنت في شيل على الجهاز. في الوقت الفعلي.

سير العمل يشغّل tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# récupère les liens de connexion
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

وهذه الروابط تُكتب مباشرة في README المستودع بواسطة سكريبت Python. تفتح مستودعك، ترى رابط الاتصال، تضغط. ها أنت في VPS الخاص بك.

المشكلة الأولى حُلّت. لكن الثانية هي المجنونة حقاً.


المشكلة 2: git كقرص صلب

هذا هو الأمر المهول.

الجهاز يُمحى مع كل تشغيلة. لذلك نخزّن نظام الملفات في فرع git مخصص، يُسمى filesystem.

عند بدء التشغيل، السكريبت يستعيد الحالة من هذا الفرع:

filesystem_branch="filesystem"

# récupère la branche filesystem depuis le remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restore le workspace depuis cette branche
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

فرع filesystem هو قرصك الصلب. ملفاتك، تثبيتاتك، إعداداتك -- كل شيء فيه.

هل ترى الفكرة؟ الجهاز قابل للرمي، لكن القرص يعيش في git. تعيد تشغيل سير العمل، يُستعاد القرص، تستأنف تماماً من حيث كنت.

هذا يشبه VPS في وضع السبات. الفرق أن السبات هو مستودع git xD

التشغيل الأول: إنشاء القرص الفارغ

في أول تشغيلة، فرع filesystem غير موجود بعد. يجب إنشاؤه. وهذا ليس بسيطاً:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan هو المفتاح. الفرع اليتيم هو فرع بدون أي تاريخ -- وكأنك تبدأ من مستودع فارغ.

لماذا يتيم؟ لأنك لا تريد أن يجرّ قرصك الدائم كل تاريخ كودك المصدر. القرص شيء منفصل، له حياته الخاصة. يبدأ فارغاً.

و git ls-remote --exit-code في البداية، هو مجرد فحص نظيف: "هل الفرع موجود بالفعل على الـ remote؟". إذا نعم، لا نلمس شيئاً. إذا لا، ننشئه. Idempotent، كما نحب.

الـ git clean الانتقائي: حماية المخابئ

هذا السطر يستحق التوقف عنده:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx يزيل كل ما ليس متتبعاً من قبل git. عادةً هذا عنيف -- ينظف workspace بالكامل.

لكن الـ -e (exclude) يحمي بعض الأشياء:

  • .apt-cache → مخبأ حزم APT (سنعود إليه، إنه ذكي)
  • .cache → مخبأ عام
  • host.conf → عنوان SSH للجلسة
  • tmate.sock → socket جلسة tmate الحالية

إذا نظّفت هذه الملفات، لَكسَرت الجلسة النشطة أو خسرت مخبأك. لذلك نُبقيها أثناء إعادة التعيين.

تفصيلة تافهة للوهلة الأولى، لكن بدونها كل شيء ينهار.


الحفظ التلقائي: inotify يراقب كل شيء

حسناً، لكن كيف تصل الملفات إلى فرع filesystem؟

الإجابة: مراقب يراقب كل تغييرات الملفات ويقوم بـ commit/push تلقائياً.

الأداة السحرية هي inotifywait (من حزمة inotify-tools). يراقب نظام الملفات على مستوى النواة ويُفعّل حالما يتغير ملف.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce si plein de changements d'un coup
  done
}

autosave &

دعنا نحلّل أعلام inotify، لأن كل واحد مهم:

  • -r → تكراري، يراقب كل المجلدات الفرعية
  • -e modify,create,delete,move → يتفاعل مع هذه الأنواع الأربعة من الأحداث
  • --exclude '...' → regex لتجاهل بعض الملفات

الـ --exclude حاسم. انظر ما يتجاهله:

  • .git → طبعاً، وإلا كل commit سيُفعّل حفظاً تلقائياً يُفعّل commitاً... حلقة لا نهائية. كارثة.
  • .apt-cache و .cache → المخابئ، التي تتغير طوال الوقت ولا نريد إغراق git بها
  • host.conf و tmate.sock → ملفات الجلسة، التي تتغير باستمرار
  • .gitignore, .txt.swp → الملفات المؤقتة (.swp هي ملفات تحرير vim)

بدون هذا الاستثناء، ستجد الحفظ التلقائي يُفعّل في حلقة على تغييراته الخاصة. .git في القائمة، هو السطر الذي يمنعك من إطلاق النار على قدمك.

تعدّل ملفاً؟ inotify يكتشفه فوراً، يقوم commit، push. في أقل من ثانية، تغييرك في فرع filesystem.

تثبّت شيئاً، تكتب كوداً، تلمس إعداداً -- كل شيء يُحفظ في الوقت الفعلي، تلقائياً، دون أن تفعل أي شيء.

لديك حرفياً نظام حفظ تلقائي للقرص بأكمله. مهول.

الـ Debounce: لا تُغرِق git

sleep 1 بعد كل حفظ هو debounce.

عندما تحفظ ملفاً في محرر، غالباً ما يُولّد عدة أحداث نظام ملفات بشكل متتابع (إنشاء ملف مؤقت، rename، حذف القديم...). بدون debounce، ستُفعّل 3-4 commits لحفظ واحد فقط.

sleep 1 يقول: "انتظر ثانية بعد الحفظ، ريثما تهدأ الموجة، قبل أن تستمع من جديد". هذا يجمع التغييرات المتقاربة في commit واحد. ذكي.

وحفظ دوري أيضاً

في حال فات inotify شيئاً، يوجد أيضاً حفظ كل 5 ثوانٍ:

periodic_save() {
  while true; do
    sync_from_remote   # récupère les changements distants éventuels
    sleep 5
    commit_and_push
  done
}

periodic_save &

حزام وأيضاً حمّالات. لا نريد أبداً أن نفقد حالة القرص.


التفصيلة الذكية: commit واحد فقط

إذا قمت بـ commit عند كل تغيير ملف، ستتراكم آلاف الـ commits. في ساعة من الجلسة، تاريخ git سينفجر. المستودع سيصبح ضخماً. هذا مقرف.

الحل أنيق: نُعدّل الـ commit الموجود بدلاً من إنشاء واحد جديد.

commit_and_push() {
  (
    flock -n 200 || return   # lock pour pas que deux saves tournent en même temps

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # touche pas aux scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : écrase le commit précédent
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend يعني: "استبدل آخر commit بهذا".

وبالتالي فرع filesystem لديه دائماً commit واحد فقط. بغض النظر عن عدد مرات الحفظ. إنها مجرد لقطة للحالة الحالية، force-push مراراً وتكراراً.

flock هو قفل: بما أن هناك حلقتا حفظ (inotify + دوري)، يجب تجنب تشغيل git في نفس الوقت وتداخلهما. عملية git واحدة في كل مرة.

نظيف.


sync_from_remote: التعامل مع جلسات متعددة

هذا شيء لم تفكر فيه في البداية: ماذا لو شغّلت تشغيلتين في نفس الوقت؟ أو إذا عدّلت جلسة فرع filesystem بينما جلسة أخرى تعمل؟

السكريبت يتعامل مع هذا بـ sync_from_remote قبل كل commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only) مهم: يعني "ادمج فقط إذا أمكن التقدم بشكل نظيف، دون إنشاء commit دمج".

إذا تفرّع الفرعان (مثلاً، جلستان عدّلتا أشياء مختلفة)، يفشل الـ fast-forward بصمت (2>/dev/null || true) ونحتفظ بالحالة المحلية. ليس نظام دمج مثالياً، لكنه يتجنب التلف في الحالة البسيطة حيث تعمل جلسة واحدة فقط.

بصراحة، لا يجب تشغيل 3 جلسات بالتوازي على نفس المستودع. لكن الكود يحاول على الأقل ألا ينفجر إذا حدث ذلك. هذا دفاع.


مخبأ APT: التثبيت السريع

هناك تفصيلة في سير العمل لا تلفت الانتباه لكنها مدروسة جيداً:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate و inotify-tools يُثبّتان عبر إجراء يخزّن حزم APT مؤقتاً.

في التشغيل الأول، يُحمّل ويُثبّت. في التشغيلات التالية، يُستعاد من مخبأ GitHub Actions -- أسرع، لا حاجة لإعادة التحميل.

وتتذكر git clean -fdx -e .apt-cache من قبل؟ هذا مرتبط. مجلد .apt-cache محمي من التنظيف تحديداً لكي تستمر الحزم التي تثبّتها أثناء جلستك بشكلٍ ما.

كل شيء مترابط. لقد فكرت في دورة الحياة الكاملة.


السكريبتات المخبأة في /tmp

تفصيلة أخرى خبيثة لكن ذكية. في بداية السكريبت:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

السكريبتات (update_readme.py، إلخ) تُنسخ إلى /tmp قبل لمس فرع filesystem.

لماذا؟ لأنه عندما تقوم بـ git reset --hard إلى فرع filesystem (الفارغ في البداية، أو الذي يحتوي على قرصك)، تختفي ملفات .github/scripts من المستودع المصدر من workspace.

لكن السكريبت لا يزال بحاجة إليها أثناء الجلسة (لتحديث README عند كل إعادة تشغيل tmate). لذلك يُخبّئها في /tmp، خارج متناول git:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

إذا لم تفكر في هذا، ستتوه 30 دقيقة محاولاً فهم لماذا اختفى سكريبتك. لقد فكرت فيه.


الشيل المخصص

راحة صغيرة: الجلسة تعطيك شيلاً مهيئاً، ليس bash عارياً.

prestart.sh ينسخ .bashrc مخصص:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

وهذا .bashrc يحتوي على prompt ملون، aliases (ll، lla، rm -i)، والأهم override لـ exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D fait pareil que exit
bind -x '"\C-d": "exit"'

عندما تكتب exit (أو Ctrl+D)، يقتل عمليات tmate بشكل نظيف قبل الإغلاق. هذا يتجنب ترك جلسات tmate زومبي.

يوجد أيضاً دالة tmate-detach إذا أردت فصل نفسك دون قتل الجلسة (لتتصل لاحقاً). تفصيلة راحة، لكنها تُظهر مستوى العناية.


tmate الذي يعيد تشغيل نفسه

راحة صغيرة: إذا كتبت exit في شيلك، عادةً جلسة tmate تموت وتنقطع للأبد.

إلا أنه هنا، tmate في حلقة while true:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

تكتب exit؟ الجلسة تعيد تشغيل نفسها تلقائياً. تتصل مجدداً بنفس الرابط.

هذا سخيف، لكنه يجعل الشيء usable.


إعادة الاتصال بأمر واحد

كيف تعيد الاتصال بعد انقطاع، دون البحث في سجلات التشغيل في كل مرة؟

عنوان SSH لـ tmate يُكتب في ملف host.conf، مُلتزم في فرع filesystem:

printf '%s' "${tmate_ssh#ssh }" > host.conf

وبما أن هذا الملف في git، يمكنك استرجاعه عبر API GitHub بأمر واحد:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"
"

تشغّل هذا، يذهب ليجلب عنوان SSH الحالي من المستودع، ويتصل بك. حتى لو تغير العنوان بين جلستين.

---

## التدفق الكامل

لنلخّص:

1. تُشغّل سير العمل (push أو زر يدوي)
2. GitHub يعطيك VM Ubuntu
3. السكريبت يستعيد القرص من فرع "filesystem"
4. inotify يبدأ بمراقبة كل التغييرات
5. periodic_save يقوم بـ commit كل 5 ثوانٍ كنسخ احتياطي
6. tmate يبدأ → يولّد روابط SSH/الويب
7. الروابط تُكتب في README + host.conf
8. تتصل عبر SSH أو الطرفية في المتصفح
9. تفعل ما تريد -- كل تغيير ملف = حفظ تلقائي
10. بعد 6 ساعات، GitHub يقتل VM
11. قرصك سليم في فرع "filesystem"
12. تعيد تشغيل سير العمل → العودة إلى الخطوة 3، كل شيء لا يزال موجوداً

VPS مجاني بقرص دائم. فقط باستخدام git و GitHub Actions.

---

## حسناً، لنكن صادقين: الحدود

هذا اختراق، ليس VPS حقيقياً. لذلك:

- **6 ساعات كحد أقصى لكل تشغيلة.** يجب إعادة تشغيل سير العمل بانتظام. لا uptime غير محدود.
- **ليس للإنتاج.** لن تستضيف موقعك عليه. هذا للاستكشاف، التطوير، التصحيح، اختبار شيء في Linux قابل للرمي لكن قابل للاستعادة.
- **GitHub يرى كل شيء.** هذه أجهزتهم. لا تضع أي شيء حساس.
- **اجعل المستودع خاصاً.** أنت تعرض شيل SSH. مستودع عام = أي شخص يمكنه الاتصال. فكرة سيئة.
- **هذا على حافة شروط الاستخدام.** GitHub Actions مصممة لـ CI/CD، ليس لـ VPS مجاني. لذا استخدمه باعتدال، لأغراض مشروعة، دون إساءة.

### كعب الأخيل الحقيقي: git يكره الملفات الكبيرة

Git مصمم للنصوص، ليس لنظام ملفات.

القرص الدائم يعيش في فرع git. لذا كل ما تحفظه يمر عبر git. و git:
- يتعامل بشكل سيء مع الملفات الثنائية الكبيرة (صورة Docker بحجم 2 جيجا في git؟ انسَ)
- لديه حد 100 ميجا لكل ملف على GitHub (حد صلب، لا يدفع لأكثر من ذلك)
- يوصي بالبقاء تحت ~5 جيجا لكل مستودع

لذا إذا قمت بـ `npm install` لمشروع مع 500 ميجا من `node_modules`، أو بنيت شيئاً ينتج ملفات ثنائية ثقيلة، فإن push إلى `filesystem` إما سيتعطل بشدة، أو سيفشل تماماً.

`git commit --amend` يساعد (commit واحد، لا تاريخ متضخم)، لكنه لا يغير حقيقة أن ملف 200 ميجا لن يمر أبداً.

باختصار: **هذا يعمل رائعاً للكود، الإعدادات، الملفات الصغيرة. لا يعمل لتخزين بيانات كبيرة أو قطع أثرية ثنائية.** يجب أن تضع هذا في اعتبارك أثناء ما تفعله في جلستك.

### ليس لقطة نظام كاملة

فارق مهم آخر: فرع `filesystem` يحفظ **workspace** (مجلد المستودع)، ليس النظام بأكمله.

إذا قمت بـ `apt install htop`، الملف الثنائي يذهب إلى `/usr/bin/htop`، وهو خارج workspace. لذلك لن يُحفظ. في التشغيل التالي، ستحتاج لإعادة تثبيته.

لهذا لدينا مخبأ APT و `prestart.sh`: لإعادة تهيئة بيئة النظام في كل بداية تشغيل، بما أن workspace فقط هو الذي يستمر.

إذا أردت أن تنجو تثبيتاتك، يجب وضعها في workspace (مثلاً، التثبيت في مجلد محلي بدلاً من النظام). هذه جمناستيكة يجب استيعابها.

---

## VPS مجاني مقابل VPS حقيقي: المقارنة

| | repo-to-vps | VPS حقيقي (5€/شهر) |
|---|---|---|
| **السعر** | 0€ | ~5-10€/شهر |
| **Uptime** | 6 ساعات، يُعاد التشغيل | 24/7 |
| **القرص** | فرع git، ملفات صغيرة | SSD حقيقي، عدة جيجا |
| **الرام** | ~7 جيجا (سخي!) | 1-2 جيجا غالباً |
| **المعالج** | 2-4 أنوية جيدة | 1-2 vCPU |
| **الإعداد** | clone قالب | إعداد يدوي |
| **الاستمرارية** | workspace فقط | النظام كاملاً |
| **الشرعية** | على حافة الشروط | 100% نظيف |

الشيء المضحك هو أنه من حيث المواصفات الخام (الرام، المعالج)، مشغل GitHub غالباً أفضل من VPS بـ 5€. لكن uptime 6 ساعات والاستمرارية المحدودة بالـ workspace، هذا ما يجعله لعبة هاكر، وليس خادماً حقيقياً.

للتعلم، الاختبار، تصحيح شيء Linux بسرعة في بيئة قابلة للاستعادة؟ ممتاز. لاستضافة أي شيء جدي؟ خذ VPS حقيقياً.

لكن لبيئة Linux مؤقتة يمكنك استعادتها متى شئت؟ إنه رائع فقط.

---

## النمط وراء كل هذا

إذا أخذت مسافة، repo-to-vps وبوت الإيميل (مقالتي الأخرى) يقومان على نفس الفكرة:

> **Git ليس مجرد مدير إصدارات. إنه نظام تخزين دائم، مجاني، مُرقّم، يمكن الوصول إليه عبر API.**

ما أن يكون لديك نظام عديم الحالة (GitHub Actions، Worker، دالة serverless) وتريد الاحتفاظ بحالة بين تشغيلتين، يمكن لـ git أن يعمل كـ "قرص".

- بوت الإيميل يخزّن `lastId` في وسم git.
- repo-to-vps يخزّن نظام ملفات كاملاً في فرع git.

نفس النمط، مقياسان. قيمة في جانب، قرص في الجانب الآخر.

و `git commit --amend` + force-push هي التقنية المشتركة: **تبقي على commit واحد يمثل الحالة الحالية، يُمسح مع كل تحديث.**

ليس مصمماً لهذا. لكنه يعمل. ومجاني.

---

**3 أشياء لتتذكرها:**

1. **فرع git = قرص صلب دائم** -- خزّن نظام ملفاتك في فرع مخصص، استعده عند بدء التشغيل، وستحصل على حالة تنجو من الأجهزة القابلة للرمي.

2. **inotify + git = حفظ تلقائي فوري** -- `inotifywait` يراقب التغييرات على مستوى النواة ويدفع إلى git فوراً. مع `git commit --amend` للحفاظ على commit واحد نظيف.

3. **tmate يحوّل مشغلاً إلى VPS** -- SSH مباشر على جهاز GitHub Actions، مع إعادة تشغيل تلقائية وإعادة اتصال بأمر واحد عبر API GitHub.

Git كقرص صلب، الحلقة الثانية. أعتقد أنني سأنتهي بتخزين كل شيء في فروع git xD

Repo to VPS: biến GitHub Actions thành VPS miễn phí với bộ nhớ liên tục

Cách biến một runner GitHub Actions thành VPS vĩnh viễn với git làm bộ nhớ liên tục -- tmate, inotify và commit --amend.

GitHub cho bạn một VPS miễn phí trong 6h. Tôi đã tìm ra cách biến nó thành vĩnh viễn.

GitHub Actions cho bạn máy Linux miễn phí.

Đại loại, máy chủ Ubuntu thật sự. 2 nhân, 7 GB RAM, 14 GB ổ cứng. Miễn phí. Trong 6h mỗi lần chạy.

"Vấn đề" duy nhất: khi kết thúc lượt chạy, mọi thứ đều bị xóa. Máy ảo dùng một lần. Bạn cài đặt, code, cấu hình... và rồi vèo, cuối cùng mọi thứ biến mất. Như chưa từng làm gì.

Trừ khi.

Trừ khi bạn dùng git làm ổ cứng.

Và thế là, bỗng nhiên bạn có một VPS miễn phí với ổ cứng bền vững sống sót qua các lượt chạy. Bạn kết nối lại, mọi thứ vẫn còn nguyên. Bạn tiếp tục từ chỗ bạn dừng lại.

Hoàn toàn điên rồ. Để tôi giải thích xD


Bối cảnh: runner GitHub Actions

Khi bạn chạy một workflow GitHub Actions, GitHub cấp cho bạn một VM.

Nó được tạo ra để build code, chạy test, deploy. Workflow chạy, làm việc của nó, và máy ảo bị phá hủy.

Nhưng không gì ngăn bạn làm việc khác với VM này. Ví dụ, mở một shell SSH trên đó và dùng nó như một máy chủ.

Vấn đề là, những máy này stateless và tạm thời:

  • Tạm thời: tối đa 6h mỗi lần chạy (timeout-minutes: 360, giới hạn của GitHub)
  • Stateless: mọi thứ bị xóa khi kết thúc

Vậy để biến nó thành VPS dùng được, cần giải quyết hai vấn đề:

  1. Làm sao kết nối vào nó theo thời gian thực?
  2. Làm sao giữ ổ cứng giữa các lượt chạy?

Đây mới là hack bẩn thỉu.


Vấn đề 1: SSH live với tmate

tmate là một nhánh của tmux tạo ra một session SSH có thể chia sẻ.

Bạn chạy nó trên một máy, nó tạo ra hai liên kết:

Bạn kết nối bằng một trong các liên kết đó, và boom, bạn ở trong một shell trên máy. Thời gian thực.

Workflow chạy tmate:

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# lấy các liên kết kết nối
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Và những liên kết này được ghi trực tiếp vào README của repo bằng script Python. Bạn mở repo, thấy liên kết kết nối, bạn nhấp vào. Bạn đã ở trong VPS của mình.

Vấn đề đầu tiên đã giải quyết. Nhưng vấn đề thứ hai mới thực sự điên rồ.


Vấn đề 2: git làm ổ cứng

Đây là thứ bệnh hoạn.

Máy bị xóa sau mỗi lượt chạy. Vậy chúng ta lưu trữ hệ thống tệp tin trong một nhánh git riêng, gọi là filesystem.

Khi khởi động, script khôi phục trạng thái từ nhánh này:

filesystem_branch="filesystem"

# lấy nhánh filesystem từ remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# khôi phục workspace từ nhánh này
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

Nhánh filesystem CHÍNH LÀ ổ cứng của bạn. Tệp của bạn, cài đặt của bạn, cấu hình của bạn -- tất cả đều ở trong đó.

Bạn thấy không? Máy dùng một lần, nhưng ổ cứng sống trong git. Bạn chạy lại workflow, ổ cứng được khôi phục, bạn tiếp tục chính xác chỗ bạn đã dừng.

Cứ như VPS ngủ đông. Chỉ khác ngủ đông là một repo git xD

Lần chạy đầu tiên: tạo ổ cứng trống

Ở lần chạy đầu tiên, nhánh filesystem chưa tồn tại. Phải tạo nó. Và điều này không hề đơn giản:

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan là chìa khóa. Một nhánh orphan là một nhánh không có bất kỳ lịch sử nào -- như thể bạn bắt đầu lại từ một repo trống.

Tại sao orphan? Bởi vì bạn KHÔNG muốn ổ cứng của bạn kéo theo toàn bộ lịch sử code nguồn. Ổ cứng là một thứ riêng, có cuộc sống riêng. Nó bắt đầu trống.

Và git ls-remote --exit-code ở đầu, chỉ là một kiểm tra sạch sẽ: "nhánh này đã tồn tại trên remote chưa?". Nếu rồi, không động gì. Nếu chưa, tạo nó. Idempotent, như chúng ta thích.

Git clean chọn lọc: bảo vệ cache

Dòng này đáng để dừng lại:

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx xóa TẤT CẢ những gì không được git theo dõi. Bình thường nó khá mạnh -- dọn sạch workspace triệt để.

Nhưng các -e (exclude) bảo vệ một số thứ:

  • .apt-cache → cache của gói APT (sẽ quay lại, rất thông minh)
  • .cache → cache chung
  • host.conf → địa chỉ SSH của session
  • tmate.sock → socket của session tmate hiện tại

Nếu bạn dọn những tệp đó, bạn sẽ làm hỏng session hiện tại hoặc mất cache. Vậy nên chúng được tha trong quá trình reset.

Một chi tiết nhỏ nhặt thoạt nhìn, nhưng không có nó mọi thứ đổ vỡ.


Tự động lưu: inotify giám sát mọi thứ

Nhưng, làm thế nào các tệp được đưa vào nhánh filesystem?

Câu trả lời: một watcher giám sát TẤT CẢ các thay đổi tệp và tự động commit/push.

Công cụ kỳ diệu là inotifywait (từ gói inotify-tools). Nó giám sát hệ thống tệp ở cấp kernel và kích hoạt ngay khi một tệp thay đổi.

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce nếu có nhiều thay đổi cùng lúc
  done
}

autosave &

Phân tích các flag inotify, vì mỗi cái đều có lý do:

  • -r → đệ quy, giám sát tất cả thư mục con
  • -e modify,create,delete,move → phản ứng với 4 loại sự kiện này
  • --exclude '...' → regex để bỏ qua một số tệp

--exclude rất quan trọng. Xem nó bỏ qua gì:

  • .git → hiển nhiên, nếu không mỗi commit sẽ kích hoạt autosave rồi lại kích hoạt commit... vòng lặp vô hạn. Thảm họa.
  • .apt-cache và .cache → cache, thay đổi liên tục và không muốn spam vào git
  • host.conf và tmate.sock → tệp session, thay đổi không ngừng
  • .gitignore, .txt.swp → tệp tạm thời (.swp là tệp soạn thảo của vim)

Nếu không có exclude này, bạn sẽ gặp autosave kích hoạt vòng lặp trên chính các thay đổi của nó. .git trong danh sách, đó là DÒNG ngăn bạn tự bắn vào chân mình.

Bạn sửa một tệp? inotify phát hiện ngay lập tức, nó commit, nó push. Trong chưa đầy một giây, thay đổi của bạn đã ở trong nhánh filesystem.

Bạn cài một công cụ, bạn viết code, bạn sửa cấu hình -- mọi thứ được lưu trong thời gian thực, tự động, không cần bạn làm gì.

Bạn thực sự có một hệ thống tự động sao lưu toàn bộ ổ cứng. Điên rồ.

Debounce: không spam git

sleep 1 sau mỗi lần lưu là một debounce.

Khi bạn lưu tệp trong trình soạn thảo, thường có nhiều sự kiện hệ thống tệp phát ra liên tiếp (tạo tệp tạm, rename, xóa tệp cũ...). Không có debounce, bạn sẽ kích hoạt 3-4 commit cho một lần lưu.

sleep 1 nói: "chờ một giây sau khi lưu, để loạt sự kiện lắng xuống, trước khi lắng nghe lại". Nó gom các thay đổi gần nhau vào một commit duy nhất. Thông minh.

Và thêm một bản lưu định kỳ

Phòng trường hợp inotify bỏ sót thứ gì, cũng có một bản lưu mỗi 5 giây:

periodic_save() {
  while true; do
    sync_from_remote   # lấy các thay đổi từ xa nếu có
    sleep 5
    commit_and_push
  done
}

periodic_save &

Vừa đai vừa dây treo. Không muốn mất trạng thái ổ cứng.


Chi tiết thông minh: chỉ một commit

Nếu bạn commit mỗi khi tệp thay đổi, bạn sẽ tích lũy hàng ngàn commit. Trong một giờ session, lịch sử git của bạn nổ tung. Repo trở nên khổng lồ. Thật kinh khủng.

Giải pháp rất thanh lịch: chúng ta amend commit hiện tại thay vì tạo commit mới.

commit_and_push() {
  (
    flock -n 200 || return   # lock để hai luồng lưu không chạy cùng lúc

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # đừng động vào scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: ghi đè commit trước đó
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend nghĩa là: "thay thế commit cuối cùng bằng commit này".

Vì vậy nhánh filesystem LUÔN LUÔN chỉ có một commit. Không quan trọng bạn lưu bao nhiêu lần. Nó chỉ là một snapshot của trạng thái hiện tại, force-push hết lần này đến lần khác.

flock là một khóa: vì có hai vòng lặp lưu (inotify + định kỳ), cần tránh chúng chạy git cùng lúc và giẫm lên nhau. Chỉ một tiến trình git tại một thời điểm.

Sạch sẽ.


Sync_from_remote: xử lý nhiều session

Ồ, một thứ bạn không nghĩ tới lúc đầu: nếu bạn chạy HAI lượt cùng lúc thì sao? Hoặc nếu một session sửa nhánh filesystem trong khi session khác đang chạy?

Script xử lý việc này bằng sync_from_remote trước mỗi commit:

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only) rất quan trọng: nó có nghĩa là "merge CHỈ KHI chúng ta có thể tiến thẳng, không tạo commit merge".

Nếu hai nhánh đã phân nhánh (kiểu, hai session sửa những thứ khác nhau), fast-forward thất bại im lặng (2>/dev/null || true) và giữ trạng thái local. Đây không phải hệ thống merge hoàn hảo, nhưng nó tránh hỏng hóc trong trường hợp đơn giản chỉ có một session chạy.

Thành thật, không nên chạy 3 session song song trên cùng một repo. Nhưng code vẫn cố gắng không phát nổ nếu điều đó xảy ra. Đó là phòng vệ.


Cache APT: cài đặt nhanh

Có một chi tiết trong workflow trông không quan trọng nhưng được thiết kế tốt:

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate và inotify-tools được cài qua một action cache các gói APT.

Ở lần chạy đầu tiên, nó tải xuống và cài đặt. Ở các lần sau, nó được khôi phục từ cache GitHub Actions -- nhanh hơn, không cần tải lại.

Và bạn nhớ git clean -fdx -e .apt-cache lúc nãy không? Có liên quan đấy. Thư mục .apt-cache được bảo vệ khỏi dọn dẹp chính xác để các gói bạn cài trong session có thể tồn tại tối thiểu.

Mọi thứ kết nối với nhau. Tôi đã nghĩ về toàn bộ vòng đời.


Các script giấu trong /tmp

Một chi tiết lắt léo nhưng thông minh nữa. Ngay đầu script:

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Các script (update_readme.py, v.v.) được sao chép vào /tmp TRƯỚC KHI đụng vào nhánh filesystem.

Tại sao? Bởi vì khi bạn làm git reset --hard về nhánh filesystem (lúc đầu trống, hoặc chứa ổ cứng của bạn), các tệp .github/scripts từ repo nguồn biến mất khỏi workspace.

Nhưng script vẫn cần chúng trong session (để cập nhật README mỗi khi tmate khởi động lại). Vậy nên nó giấu chúng trong /tmp, ngoài tầm với của git:

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

Nếu bạn không nghĩ đến điều này, bạn sẽ vật lộn 30 phút để hiểu tại sao script của bạn biến mất. Tôi đã nghĩ đến nó.


Shell tùy chỉnh

Một chút tiện nghi: session cấp cho bạn một shell đã cấu hình, không phải bash trần trụi.

prestart.sh sao chép một .bashrc tùy chỉnh:

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

Và .bashrc này chứa prompt màu sắc, alias (ll, lla, rm -i), và đặc biệt là ghi đè exit:

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D làm tương tự exit
bind -x '"\C-d": "exit"'

Khi bạn gõ exit (hoặc Ctrl+D), nó giết sạch các tiến trình tmate trước khi đóng. Tránh để lại các session tmate zombie.

Cũng có hàm tmate-detach nếu bạn muốn ngắt kết nối MÀ KHÔNG giết session (để kết nối lại sau). Chi tiết tiện nghi, nhưng cho thấy mức độ chăm chút.


Tmate tự khởi động lại

Tiện nghi nhỏ: nếu bạn gõ exit trong shell, bình thường session tmate chết và bạn mất kết nối vĩnh viễn.

Nhưng ở đây, tmate nằm trong vòng lặp while true:

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

Bạn exit? Session tự động khởi động lại. Bạn kết nối lại với cùng liên kết.

Thật điên rồ, nhưng nó làm cho công cụ trở nên dùng được.


Kết nối lại bằng một lệnh

Làm thế nào để kết nối lại sau khi mất kết nối, mà không phải lục tung log của run mỗi lần?

Địa chỉ SSH của tmate được ghi vào tệp host.conf, được commit trong nhánh filesystem:

printf '%s' "${tmate_ssh#ssh }" > host.conf

Vì tệp này nằm trong git, bạn có thể lấy nó qua API GitHub bằng một lệnh duy nhất:

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

Bạn chạy lệnh này, nó lấy địa chỉ SSH hiện tại từ repo và kết nối bạn. Kể cả nếu địa chỉ đã thay đổi giữa các session.


Luồng hoàn chỉnh

Tóm lại:

  1. Bạn kích hoạt workflow (push hoặc nút thủ công)
  2. GitHub cấp cho bạn một VM Ubuntu
  3. Script khôi phục ổ cứng từ nhánh "filesystem"
  4. inotify bắt đầu giám sát mọi thay đổi
  5. periodic_save commit mỗi 5s dự phòng
  6. tmate khởi động → tạo các liên kết SSH/web
  7. Các liên kết được ghi vào README + host.conf
  8. Bạn kết nối bằng ssh hoặc terminal web
  9. Bạn làm gì tùy thích -- mỗi thay đổi tệp = autosave
  10. 6h sau, GitHub giết VM
  11. Ổ cứng của bạn còn nguyên trong nhánh "filesystem"
  12. Bạn chạy lại workflow → quay lại bước 3, mọi thứ vẫn còn

Một VPS miễn phí với ổ cứng bền vững. Chỉ với git và GitHub Actions.


Thành thật mà nói: những giới hạn

Đây là hack, không phải VPS thật. Vậy nên:

  • Tối đa 6h mỗi lần chạy. Phải chạy lại workflow thường xuyên. Không có uptime vô hạn.
  • Không dùng cho production. Bạn sẽ không host trang web ở đây. Nó dành cho khám phá, dev, debug, thử nghiệm một thứ trong Linux dùng một lần nhưng có thể phục hồi.
  • GitHub thấy mọi thứ. Đó là máy của họ. Đừng đặt gì nhạy cảm.
  • Giữ repo ở chế độ private. Bạn đang phơi bày một shell SSH. Repo public = bất kỳ ai cũng có thể kết nối. Ý tưởng tồi.
  • Nó ở ranh giới điều khoản sử dụng. GitHub Actions được tạo cho CI/CD, không phải VPS miễn phí. Vậy hãy dùng có chừng mực, cho mục đích chính đáng, không lạm dụng.

Điểm yếu thực sự: git ghét tệp lớn

Git được tạo cho văn bản, không phải cho hệ thống tệp.

Ổ cứng sống trong một nhánh git. Vậy mọi thứ bạn lưu đều qua git. Và git:

  • xử lý kém các tệp nhị phân lớn (một image Docker 2 GB trong git? quên đi)
  • có giới hạn 100 MB mỗi tệp trên GitHub (hard limit, không push được qua)
  • khuyến nghị dưới ~5 GB mỗi repo

Vậy nếu bạn npm install một dự án với 500 MB node_modules, hoặc build thứ gì đó ra tệp nhị phân nặng, push lên filesystem sẽ hoặc rất chậm, hoặc hoàn toàn thất bại.

git commit --amend giúp ích (chỉ một commit, không lịch sử phình to), nhưng không thay đổi sự thật rằng một tệp 200 MB sẽ không bao giờ qua được.

Tóm lại: nó hoạt động tuyệt vời cho code, cấu hình, tệp nhỏ. Nó không hoạt động để lưu dữ liệu lớn hoặc artifact nhị phân. Phải ghi nhớ điều này khi làm việc trong session của bạn.

Đây không phải snapshot hệ thống hoàn chỉnh

Một sắc thái quan trọng khác: nhánh filesystem lưu workspace (thư mục của repo), không phải toàn bộ hệ thống.

Nếu bạn apt install htop, tệp nhị phân sẽ vào /usr/bin/htop, nằm NGOÀI workspace. Vậy nó sẽ KHÔNG được lưu. Ở lần chạy sau, phải cài lại.

Đó là lý do có cache APT và prestart.sh: để chuẩn bị lại môi trường hệ thống mỗi lần khởi động, vì chỉ workspace mới tồn tại.

Nếu bạn muốn các cài đặt của mình sống sót, phải đặt chúng trong workspace (kiểu, cài trong thư mục local thay vì hệ thống). Đó là một bài tập cần làm quen.


VPS miễn phí vs VPS thật: so sánh

repo-to-vps VPS thật (5€/tháng)
Giá 0€ ~5-10€/tháng
Uptime 6h, phải chạy lại 24/7
Ổ cứng nhánh git, tệp nhỏ SSD thật, nhiều GB
RAM ~7 GB (hào phóng!) 1-2 GB thường
CPU 2-4 nhân ổn 1-2 vCPU
Thiết lập clone template cấu hình thủ công
Tồn tại workspace chỉ hệ thống hoàn chỉnh
Hợp pháp ranh giới ĐKSD 100% sạch sẽ

Điều buồn cười là về specs thô (RAM, CPU), runner GitHub thường TỐT HƠN VPS 5€. Nhưng uptime 6h và tồn tại giới hạn trong workspace, đó là thứ biến nó thành đồ chơi hacker, không phải máy chủ thật.

Để học, thử nghiệm, debug Linux nhanh trong môi trường có thể phục hồi? Hoàn hảo. Để host bất cứ thứ gì nghiêm túc? Hãy mua VPS thật.

Nhưng cho môi trường Linux tạm thời mà bạn có thể khôi phục tùy ý? Nó thật sự tuyệt vời.


Pattern đằng sau tất cả

Nếu bạn lùi lại, repo-to-vps và bot email (bài viết khác của tôi) đều dựa trên cùng một ý tưởng:

Git không chỉ là trình quản lý phiên bản. Nó là hệ thống lưu trữ bền vững, miễn phí, có phiên bản, có thể truy cập qua API.

Khi bạn có một hệ thống stateless (GitHub Actions, Worker, serverless function) và muốn giữ trạng thái giữa các lần thực thi, git có thể làm "ổ cứng".

  • Bot email lưu lastId trong một tag git.
  • repo-to-vps lưu toàn bộ hệ thống tệp trong một nhánh git.

Cùng pattern, hai quy mô. Một bên là giá trị, một bên là ổ cứng.

Và git commit --amend + force-push là kỹ thuật chung: bạn giữ một commit duy nhất đại diện cho trạng thái hiện tại, bị ghi đè mỗi lần cập nhật.

Nó không được tạo ra cho việc này. Nhưng nó hoạt động. Và nó miễn phí.


3 điều cần nhớ:

  1. Một nhánh git = ổ cứng bền vững -- Lưu hệ thống tệp của bạn trong một nhánh riêng, khôi phục khi khởi động, và bạn có trạng thái sống sót qua các máy dùng một lần.

  2. inotify + git = autosave thời gian thực -- inotifywait giám sát thay đổi ở cấp kernel và push lên git ngay lập tức. Với git commit --amend để giữ một commit duy nhất, sạch sẽ.

  3. tmate biến runner thành VPS -- SSH live trên máy GitHub Actions, với tự động khởi động lại và kết nối lại bằng một lệnh qua API GitHub.

Git làm ổ cứng, tập hai. Tôi nghĩ tôi sẽ kết thúc với việc lưu mọi thứ trong các nhánh git xD

Repo to VPS : เปลี่ยน GitHub Actions เป็น VPS ฟรีพร้อมพื้นที่เก็บข้อมูลถาวร

วิธีเปลี่ยน GitHub Actions runner ให้เป็น VPS ถาวรโดยใช้ git เป็นพื้นที่เก็บข้อมูลถาวร -- tmate, inotify และ commit --amend

GitHub แจก VPS ฟรีให้คุณ 6 ชั่วโมง ฉันหาวิธีทำให้มันถาวรได้แล้ว

GitHub Actions ให้เครื่อง Linux ฟรีคุณ

ใช่แล้ว เซิร์ฟเวอร์ Ubuntu จริง ๆ 2 คอร์, RAM 7 GB, ดิสก์ 14 GB ฟรี นาน 6 ชั่วโมงต่อ run

"ปัญหา" เดียวคือ ตอนจบ run ทุกอย่างถูกลบ เครื่องถูกทิ้ง คุณติดตั้งของ เขียนโค้ด ตั้งค่า... แล้วปุ๊บ ตอนจบทุกอย่างหายไป เหมือนคุณไม่ได้ทำอะไรเลย

ยกเว้นแต่

ยกเว้นแต่คุณใช้ git เป็นฮาร์ดดิสก์

และแล้ว ทันใดนั้น คุณก็มี VPS ฟรีพร้อมดิสก์ถาวรที่อยู่รอดข้าม run คุณ reconnect ทุกอย่างยังอยู่ คุณกลับมาต่อจากที่ค้างไว้

มันบ้าแตกเลย ให้ฉันอธิบายหน่อย xD


บริบท : GitHub Actions runners

เมื่อคุณรัน workflow GitHub Actions, GitHub จะให้ VM คุณ

มันถูกออกแบบมาให้ build โค้ดของคุณ รันเทส deploy ของคุณ workflow ทำงาน เสร็จภารกิจ แล้วเครื่องก็ถูกทำลาย

แต่ไม่มีอะไรห้ามคุณทำอย่างอื่นกับ VM นี้ เช่น เปิด shell SSH บนนั้นแล้วใช้มันเป็นเซิร์ฟเวอร์

เรื่องคือ เครื่องเหล่านี้เป็น stateless และ ชั่วคราว :

  • ชั่วคราว : สูงสุด 6 ชม. ต่อ run (timeout-minutes: 360, เพดานของ GitHub)
  • Stateless : ทุกอย่างถูกลบตอนจบ

เพราะฉะนั้นเพื่อทำให้เป็น VPS ที่ใช้งานได้ ต้องแก้สองปัญหา :

  1. จะเชื่อมต่อแบบ real-time ได้ยังไง ?
  2. จะเก็บดิสก์ข้ามแต่ละ run ได้ยังไง ?

ตรงนี้แหละที่กลายเป็น hack สกปรก


ปัญหาที่ 1 : SSH แบบ live ด้วย tmate

tmate คือ fork ของ tmux ที่สร้าง session SSH ที่แชร์ได้

คุณรันมันบนเครื่อง มันจะสร้างลิงก์สองอัน :

  • URL SSH (ssh [email protected])
  • URL เว็บ (terminal ในเบราว์เซอร์)

คุณเชื่อมต่อด้วยลิงก์ใดลิงก์หนึ่ง แล้ว boom คุณอยู่ใน shell บนเครื่อง แบบ real-time

workflow ก็เลยรัน tmate :

tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
tmate -S /tmp/tmate.sock set-option -g remain-on-exit on

# ดึงลิงก์เชื่อมต่อ
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

และลิงก์เหล่านี้ถูกเขียนลง README ของ repo โดยตรงด้วย Python script คุณเปิด repo คุณเห็นลิงก์เชื่อมต่อ คุณคลิก คุณก็อยู่ใน VPS ของคุณแล้ว

ปัญหาแรกแก้ได้ แต่อันที่สองนี่บ้าจริง


ปัญหาที่ 2 : git เป็นฮาร์ดดิสก์

นี่คือสิ่งที่บ้า

เครื่องถูกลบทุก run ดังนั้นเราเก็บ ระบบไฟล์ไว้ใน branch git ที่แยกต่างหาก ชื่อว่า filesystem

ตอนเริ่มต้น script จะ restore สถานะจาก branch นี้ :

filesystem_branch="filesystem"

# ดึง branch filesystem จาก remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restore workspace จาก branch นี้
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

branch filesystem คือฮาร์ดดิสก์ของคุณ ไฟล์ของคุณ การติดตั้งของคุณ config ของคุณ -- ทุกอย่างอยู่ในนั้น

เห็นมั้ย ? เครื่องทิ้งได้ แต่ดิสก์อยู่ใน git คุณรัน workflow ใหม่ ดิสก์ถูก restore คุณกลับมาต่อตรงที่ค้างไว้

มันเหมือน VPS ที่ hibernate ต่างแค่ hibernation คือ repo git xD

รันแรก : สร้างดิสก์เปล่า

ใน run แรกสุด branch filesystem ยังไม่มี ต้องสร้างมัน และนี่ไม่ใช่เรื่องเล็ก :

ensure_filesystem_branch() {
  if ! git ls-remote --exit-code origin "refs/heads/$filesystem_branch" >/dev/null 2>&1; then
    git checkout --orphan filesystem-workspace
    git rm -rf --cached .
    git clean -fdx -e .git -e .github -e .github/scripts -e .github/workflows
    git commit --allow-empty -m "init filesystem (empty)"
    push_filesystem
  fi
}

git checkout --orphan คือกุญแจสำคัญ branch กำพร้าคือ branch ที่ไม่มีประวัติใด ๆ -- เหมือนเริ่มต้นจาก repo เปล่า

ทำไมต้องกำพร้า ? เพราะคุณไม่ต้องการให้ดิสก์ถาวรของคุณลากประวัติโค้ดต้นฉบับทั้งหมดมาด้วย ดิสก์เป็นของแยกต่างหาก มีชีวิตของมันเอง มันเริ่มต้นแบบบริสุทธิ์

และ git ls-remote --exit-code ตอนต้น ก็แค่ check สะอาด ๆ : "branch นี้มีบน remote แล้วหรือยัง ?" ถ้ามีแล้ว ก็ไม่แตะ ถ้ายังไม่มี ก็สร้าง Idempotent อย่างที่ชอบ

git clean แบบเลือกได้ : ป้องกัน cache

บรรทัดนี้ควรหยุดดู :

git clean -fdx -e .apt-cache -e .cache -e host.conf -e tmate.sock

git clean -fdx มันลบทุกอย่างที่ git ไม่ได้ track ปกติมันรุนแรง -- มันล้าง workspace หมดจด

แต่ -e (exclude) ปกป้องบางอย่าง :

  • .apt-cache → cache ของแพ็กเกจ APT (เดี๋ยวกลับมาเรื่องนี้ มันฉลาด)
  • .cache → cache ทั่วไป
  • host.conf → ที่อยู่ SSH ของ session
  • tmate.sock → socket ของ session tmate ปัจจุบัน

ถ้าคุณลบไฟล์พวกนี้ คุณจะทำลาย session ปัจจุบันหรือเสีย cache ดังนั้นเราจึงยกเว้นมันตอน reset

รายละเอียดเล็กน้อยเมื่อมองครั้งแรก แต่ถ้าไม่มีมันทุกอย่างพัง


Autosave : inotify ที่จับตาดูทุกอย่าง

แล้ว ไฟล์ต่าง ๆ ไปอยู่ใน branch filesystem ได้ยังไง ?

คำตอบ : watcher ที่เฝ้าดูการเปลี่ยนแปลงของไฟล์ทั้งหมดและ commit/push โดยอัตโนมัติ

เครื่องมือมหัศจรรย์คือ inotifywait (จากแพ็กเกจ inotify-tools) มันเฝ้าดูระบบไฟล์ในระดับ kernel และทำงานทันทีที่ไฟล์เปลี่ยน

autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock|\.gitignore|\.txt\.swp)(/|$)' .; do
    echo "[autosave] change detected"
    commit_and_push
    sleep 1   # debounce ในกรณีที่มีหลายเปลี่ยนแปลงพร้อมกัน
  done
}

autosave &

มาดู flags ของ inotify กัน แต่ละตัวสำคัญ :

  • -r → recursive เฝ้าดูทุกโฟลเดอร์ย่อย
  • -e modify,create,delete,move → ตอบสนองต่อ 4 ประเภทเหตุการณ์นี้
  • --exclude '...' → regex เพื่อไม่รวมบางไฟล์

--exclude สำคัญมาก ดูว่ามันไม่รวมอะไรบ้าง :

  • .git → แน่นอน ไม่อย่างนั้นทุก commit จะ trigger autosave ที่ trigger commit... loop อนันต์ หายนะ
  • .apt-cache และ .cache → cache ที่เปลี่ยนแปลงตลอดและเราไม่ต้องการ spam ลง git
  • host.conf และ tmate.sock → ไฟล์ session ที่เปลี่ยนแปลงตลอด
  • .gitignore, .txt.swp → ไฟล์ชั่วคราว (.swp คือไฟล์ขณะแก้ไขของ vim)

ถ้าไม่มี exclude นี้ คุณจะเจอ autosave ที่ trigger ซ้ำกับตัวเอง .git ในรายการคือบรรทัดที่ป้องกันคุณจากการยิงตัวเองตาย

คุณแก้ไขไฟล์ ? inotify ตรวจจับทันที มัน commit มัน push ภายในไม่ถึงวินาที การเปลี่ยนแปลงของคุณอยู่ใน branch filesystem

คุณติดตั้งอะไรสักอย่าง คุณเขียนโค้ด คุณแตะ config -- ทุกอย่างถูกบันทึกแบบ real-time อัตโนมัติ โดยที่คุณไม่ต้องทำอะไรเลย

คุณมีระบบสำรองข้อมูลอัตโนมัติของทั้งดิสก์ บ้าไปแล้ว

Debounce : อย่า spam git

sleep 1 หลังแต่ละ save คือ debounce

เมื่อคุณบันทึกไฟล์ใน editor มักจะเกิดเหตุการณ์ filesystem หลายครั้งเป็นระลอก (สร้าง temp file, rename, ลบของเก่า...) โดยไม่มี debounce คุณจะได้ 3-4 commit ต่อการบันทึกครั้งเดียว

sleep 1 บอกว่า : "รอ 1 วินาทีหลัง save ให้ระลอกสงบก่อนฟังอีก" มันรวมการเปลี่ยนแปลงที่ใกล้กันเป็น commit เดียว ฉลาด

และการบันทึกเป็นระยะอีกขั้น

เผื่อ inotify พลาดอะไรไป ก็มี save ทุก 5 วินาทีด้วย :

periodic_save() {
  while true; do
    sync_from_remote   # ดึงการเปลี่ยนแปลงระยะไกลที่อาจมี
    sleep 5
    commit_and_push
  done
}

periodic_save &

คาดเข็มขัดและเอาเชือกผูก เราไม่อยากเสียสถานะดิสก์เด็ดขาด


รายละเอียดที่ฉลาด : commit เดียว

ถ้าคุณ commit ทุกครั้งที่ไฟล์เปลี่ยน คุณจะสะสมเป็นพัน commit ในหนึ่งชั่วโมง ประวัติ git ของคุณจะระเบิด repo ใหญ่ขึ้น มันเละเทะ

วิธีแก้สง่างาม : เรา amend commit ที่มีอยู่ แทนที่จะสร้างใหม่

commit_and_push() {
  (
    flock -n 200 || return   # lock ป้องกันไม่ให้สอง saves รันพร้อมกัน

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # อย่าแตะ scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : ทับ commit ก่อนหน้า
      else
        git commit -m "autosave $(date -u +%Y%m%dT%H%M%SZ)"
      fi
      git push --force origin "filesystem-workspace:filesystem"
    fi
  ) 200>/tmp/tmate_autosave.lock
}

git commit --amend แปลว่า : "แทนที่ commit ล่าสุดด้วยอันนี้"

ดังนั้น branch filesystem จะมี แค่ commit เดียวเสมอ ไม่สำคัญว่าคุณบันทึกกี่ครั้ง มันเป็น snapshot ของสถานะปัจจุบัน force-push ซ้ำแล้วซ้ำเล่า

flock คือล็อก : เพราะมีสอง loop บันทึก (inotify + เป็นระยะ) ต้องป้องกันไม่ให้ทั้งคู่รัน git พร้อมกันและเหยียบกันเอง ครั้งละหนึ่ง process git เท่านั้น

สะอาด


Sync_from_remote : จัดการหลาย session

นี่คือสิ่งที่คุณอาจไม่คิดตอนแรก : แล้วถ้าคุณรัน TWO runs พร้อมกันล่ะ ? หรือถ้า session หนึ่งแก้ไข branch filesystem ขณะที่อีก session ทำงาน ?

script จัดการด้วย sync_from_remote ก่อนแต่ละ commit :

sync_from_remote() {
  git fetch origin "filesystem":refs/remotes/origin/filesystem
  git merge --ff-only "refs/remotes/origin/filesystem"
}

--ff-only (fast-forward only) สำคัญ : แปลว่า "merge เฉพาะเมื่อสามารถเดินหน้าได้อย่างสะอาด โดยไม่ต้องสร้าง merge commit"

ถ้าทั้งสอง branch แยกกัน (เช่น สอง session แก้ไขคนละอย่าง) fast-forward จะล้มเหลวเงียบ ๆ (2>/dev/null || true) และคงสถานะ local ไว้ มันไม่ใช่ระบบ merge ที่สมบูรณ์แบบ แต่ป้องกัน corruption ในกรณีง่าย ๆ ที่มีแค่ session เดียวทำงาน

จริง ๆ ไม่ควรเปิด 3 session พร้อมกันใน repo เดียว แต่โค้ดก็พยายามไม่ระเบิดถ้ามันเกิดขึ้น นี่คือการป้องกัน


Cache APT : ติดตั้งเร็ว

มีรายละเอียดใน workflow ที่ดูไม่สำคัญแต่คิดมาอย่างดี :

- name: Cache & install APT packages (tmate + watcher)
  uses: awalsh128/[email protected]
  with:
    packages: tmate inotify-tools

tmate และ inotify-tools ถูกติดตั้งผ่าน action ที่ cache แพ็กเกจ APT

ใน run แรก มันดาวน์โหลดและติดตั้ง ใน run ต่อ ๆ ไป มันถูก restore จาก cache ของ GitHub Actions -- เร็วขึ้น ไม่ต้องโหลดใหม่

และจำ git clean -fdx -e .apt-cache เมื่อกี้ได้มั้ย ? มันเกี่ยวข้องกัน โฟลเดอร์ .apt-cache ถูกป้องกันจากการล้างเพื่อให้แพ็กเกจที่คุณติดตั้งระหว่าง session สามารถคงอยู่ได้บ้าง

ทุกอย่างเชื่อมโยงกัน ฉันคิดถึงวงจรชีวิตทั้งหมดแล้ว


Scripts ที่ซ่อนใน /tmp

อีกรายละเอียดที่แสบแต่ฉลาด ตอนต้นของ script :

RUNNER_SCRIPTS_DIR="/tmp/runner-scripts"
rm -rf "$RUNNER_SCRIPTS_DIR"
mkdir -p "$RUNNER_SCRIPTS_DIR"
cp -r .github/scripts "$RUNNER_SCRIPTS_DIR/"

Scripts (update_readme.py, ฯลฯ) ถูกคัดลอกไป /tmp ก่อนที่จะแตะต้อง branch filesystem

ทำไม ? เพราะเมื่อคุณทำ git reset --hard ไปที่ branch filesystem (ซึ่งว่างตอนแรก หรือมีดิสก์ของคุณอยู่) ไฟล์ .github/scripts จาก repo ต้นทางจะหายไปจาก workspace

แต่ script ยังต้องการมันระหว่าง session (เพื่อ update README ทุกครั้งที่ tmate เริ่มใหม่) ดังนั้นมันจึงซ่อนไว้ใน /tmp ให้พ้นจาก git :

python3 "$RUNNER_SCRIPTS_DIR/scripts/update_readme.py" --ssh "$tmate_ssh" ...

ถ้าคุณไม่คิดถึงตรงนี้ คุณจะเสียเวลา 30 นาทีงงว่า script หายไปไหน ฉันคิดถึงมันแล้ว


Shell ที่ปรับแต่งเอง

ความสะดวกเล็กน้อย : session ให้ shell ที่ตั้งค่าแล้ว ไม่ใช่ bash เปล่า ๆ

prestart.sh คัดลอก .bashrc ที่กำหนดเอง :

if ! grep -q "Custom prompt and aliases for remote sessions" "$HOME/.bashrc"; then
  cp .github/scripts/remote_bashrc "$HOME/.bashrc"
fi
sudo cp "$HOME/.bashrc" /root/.bashrc

และ .bashrc นี้มี prompt สี, alias (ll, lla, rm -i), และที่สำคัญคือ override ของ exit :

exit() {
    killall -9 -u "$(whoami)" tmate 2>/dev/null || true
    builtin exit "$@"
}

# Ctrl+D ทำเหมือน exit
bind -x '"\C-d": "exit"'

เมื่อคุณพิมพ์ exit (หรือ Ctrl+D) มันจะ kill process tmate อย่างสะอาดก่อนปิด ป้องกันไม่ให้เหลือ session tmate zombie

ยังมีฟังก์ชัน tmate-detach ถ้าคุณต้องการตัดการเชื่อมต่อ โดยไม่ฆ่า session (เพื่อ reconnect ทีหลัง) รายละเอียดเพื่อความสะดวก แต่แสดงถึงระดับความใส่ใจ


tmate ที่เริ่มตัวเองใหม่

ความสะดวกเล็กน้อย : ถ้าคุณพิมพ์ exit ใน shell ปกติ session tmate จะตายและคุณจะขาดการเชื่อมต่อถาวร

แต่ที่นี่ tmate อยู่ใน loop while true :

while true; do
  tmate -S /tmp/tmate.sock new-session -d "bash --rcfile $HOME/.bashrc -i"
  while tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}' >/dev/null 2>&1; do
    sleep 2
  done
  echo "tmate session ended; restarting..."
done

คุณ exit ? session จะเริ่มใหม่เอง คุณ reconnect ด้วยลิงก์เดิม

มันบ้าแต่ทำให้มันใช้ได้


การ reconnect ด้วยคำสั่งเดียว

คุณจะ reconnect หลังจากขาดการเชื่อมต่อ โดยไม่ต้องไปค้น log ของ run ทุกครั้งได้ยังไง ?

ที่อยู่ SSH ของ tmate ถูกเขียนในไฟล์ host.conf ที่ commit ไว้ใน branch filesystem :

printf '%s' "${tmate_ssh#ssh }" > host.conf

และเพราะไฟล์นี้อยู่ใน git คุณสามารถดึงมันผ่าน GitHub API ด้วยคำสั่งเดียว :

ssh "$(gh api -H 'Accept: application/vnd.github.v3.raw' \
  "/repos/USER/REPO/contents/host.conf?ref=filesystem" | tr -d '\r\n')"

คุณรันมัน มันไปหาที่อยู่ SSH ปัจจุบันใน repo แล้วเชื่อมต่อ แม้ว่าที่อยู่จะเปลี่ยนระหว่าง session


Flow เต็ม

สรุป :

  1. คุณ trigger workflow (push หรือปุ่ม manual)
  2. GitHub ให้ VM Ubuntu คุณ
  3. Script restore ดิสก์จาก branch "filesystem"
  4. inotify เริ่มเฝ้าดูทุกการเปลี่ยนแปลง
  5. periodic_save commit ทุก 5 วินาทีเป็น backup
  6. tmate เริ่ม → สร้างลิงก์ SSH/web
  7. ลิงก์ถูกเขียนใน README + host.conf
  8. คุณเชื่อมต่อด้วย ssh หรือ terminal เว็บ
  9. คุณทำอะไรก็ได้ -- ทุกการเปลี่ยนแปลงไฟล์ = autosave
  10. 6 ชม. ต่อมา GitHub ฆ่า VM
  11. ดิสก์ของคุณยังคงอยู่ใน branch "filesystem"
  12. คุณรัน workflow ใหม่ → กลับไปขั้นตอนที่ 3 ทุกอย่างยังอยู่

VPS ฟรีพร้อมดิสก์ถาวร แค่ใช้ git และ GitHub Actions


เอาล่ะ ต้องซื่อสัตย์ : ข้อจำกัด

นี่คือ hack ไม่ใช่ VPS จริง ดังนั้น :

  • สูงสุด 6 ชม. ต่อ run. ต้องรัน workflow ซ้ำเป็นประจำ ไม่มี uptime ไม่รู้จบ
  • ไม่ใช่สำหรับ production. คุณจะไม่โฮสต์เว็บไซต์ของคุณบนนี้ มันไว้สำหรับสำรวจ, dev, debug, ทดสอบอะไรใน Linux ที่ทิ้งได้แต่กู้คืนได้
  • GitHub เห็นทุกอย่าง. มันคือเครื่องของพวกเขา อย่าใส่อะไรที่ละเอียดอ่อน
  • เก็บ repo เป็น private. คุณกำลังเปิด shell SSH การมี repo public = ใครก็ได้อาจเชื่อมต่อได้ ความคิดไม่ดี
  • มันใกล้เส้นเงื่อนไขการใช้งาน. GitHub Actions มีไว้สำหรับ CI/CD ไม่ใช่ VPS ฟรี ดังนั้นใช้อย่างพอประมาณ สำหรับสิ่งที่ชอบด้วยกฎหมาย ไม่ใช้เกินควร

จุดอ่อนจริง : git ไม่ชอบไฟล์ใหญ่

git สร้างมาสำหรับข้อความ ไม่ใช่ระบบไฟล์

ดิสก์ถาวรอาศัยอยู่ใน branch git ดังนั้นทุกอย่างที่คุณบันทึกผ่าน git และ git :

  • จัดการไฟล์ไบนารีใหญ่ได้ไม่ดี (Docker image 2 GB ใน git ? ลืมไปเลย)
  • มีขีดจำกัด 100 MB ต่อไฟล์บน GitHub (hard limit push ไม่ผ่าน)
  • แนะนำให้อยู่ต่ำกว่า ~5 GB ต่อ repo

ดังนั้นถ้าคุณ npm install โปรเจกต์ที่มี node_modules 500 MB หรือ build อะไรที่สร้างไบนารีหนัก ๆ push ไป filesystem จะช้ามากหรือล้มเหลวเลย

git commit --amend ช่วยได้ (commit เดียว ไม่มีประวัติพอง) แต่ไม่ได้เปลี่ยนความจริงที่ว่าไฟล์ 200 MB จะไม่มีทางผ่าน

โดยสรุป : มันใช้ได้ดีสำหรับโค้ด, configs, ไฟล์เล็ก ๆ ใช้ไม่ได้สำหรับเก็บข้อมูลใหญ่หรือ artefacts ไบนารี ต้องจำไว้เวลาทำอะไรใน session

มันไม่ใช่ snapshot ระบบเต็ม

ความแตกต่างสำคัญอีกอย่าง : branch filesystem บันทึก workspace (โฟลเดอร์ของ repo) ไม่ใช่ทั้งระบบ

ถ้าคุณทำ apt install htop ไบนารีไปอยู่ที่ /usr/bin/htop ซึ่งอยู่นอก workspace ดังนั้นมันจะไม่ถูกบันทึก ใน run หน้า ต้องติดตั้งใหม่

นี่คือเหตุผลที่มี cache APT และ prestart.sh : เพื่อเตรียมสภาพแวดล้อมระบบใหม่ทุกครั้งที่เริ่ม เพราะมีแค่ workspace ที่คงอยู่

ถ้าคุณต้องการให้สิ่งที่ติดตั้งอยู่รอด ต้องวางไว้ใน workspace (เช่น ติดตั้งในโฟลเดอร์ local แทนที่จะเป็นระบบ) นี่คือการปรับวิธีคิดที่ต้องทำ


VPS ฟรี vs VPS จริง : เปรียบเทียบ

repo-to-vps VPS จริง (5€/เดือน)
ราคา 0€ ~5-10€/เดือน
Uptime 6 ชม. ต้องรันใหม่ 24/7
ดิสก์ branch git, ไฟล์เล็ก SSD จริง, หลาย GB
RAM ~7 GB (ใจดีมาก !) 1-2 GB บ่อยครั้ง
CPU 2-4 คอร์พอใช้ 1-2 vCPU
Setup clone template ตั้งค่าด้วยตนเอง
การคงอยู่ workspace เท่านั้น ระบบเต็ม
ความชอบธรรม ใกล้เส้น CGU 100% สะอาด

เรื่องตลกคือในด้านสเปกดิบ (RAM, CPU), GitHub runner มักจะดีกว่า VPS 5€ ด้วยซ้ำ แต่ uptime 6 ชม. และการคงอยู่จำกัดแค่ workspace ทำให้มันเป็นของเล่น hacker ไม่ใช่เซิร์ฟเวอร์จริง

สำหรับเรียนรู้ ทดสอบ debug อะไร Linux ในสภาพแวดล้อมที่กู้คืนได้ ? เหมาะสุด สำหรับโฮสต์อะไรที่จริงจัง ? ใช้ VPS จริง

แต่สำหรับสภาพแวดล้อม Linux ชั่วคราวที่คุณสามารถ restore เมื่อไหร่ก็ได้ ? มันเจ๋งสุด ๆ


รูปแบบเบื้องหลังทั้งหมดนี้

ถ้าคุณมองในภาพกว้าง repo-to-vps และ bot email (บทความอื่นของฉัน) ตั้งอยู่บนแนวคิดเดียวกัน :

Git ไม่ใช่แค่ระบบควบคุมเวอร์ชัน มันคือระบบจัดเก็บข้อมูลถาวร ฟรี มี version เข้าถึงได้ผ่าน API

เมื่อคุณมีระบบ stateless (GitHub Actions, Worker, ฟังก์ชัน serverless) และคุณต้องการเก็บสถานะระหว่างการทำงาน git สามารถใช้เป็น "ดิสก์"

  • bot email เก็บ lastId ใน git tag
  • repo-to-vps เก็บระบบไฟล์ทั้งหมดใน git branch

รูปแบบเดียวกัน สองขนาด ค่าหนึ่งอัน ดิสก์อีกอัน

และ git commit --amend + force-push คือเทคนิคร่วม : คุณเก็บ commit เดียวที่แทนสถานะปัจจุบัน ทับทุกครั้งที่มีการอัปเดต

มันไม่ได้ออกแบบมาเพื่อสิ่งนี้ แต่มันใช้ได้ และมันฟรี


3 เรื่องที่ต้องจำ :

  1. git branch = ฮาร์ดดิสก์ถาวร -- เก็บระบบไฟล์ของคุณใน branch ที่แยกต่างหาก, restore ตอนเริ่มต้น, และคุณมีสถานะที่อยู่รอดจากเครื่องที่ทิ้งได้

  2. inotify + git = autosave แบบ real-time -- inotifywait เฝ้าดูการเปลี่ยนแปลงระดับ kernel และ push ไป git ทันที ด้วย git commit --amend เพื่อเก็บ commit เดียวที่สะอาด

  3. tmate เปลี่ยน runner เป็น VPS -- SSH แบบ live บนเครื่อง GitHub Actions พร้อม restart อัตโนมัติและ reconnect ด้วยคำสั่งเดียวผ่าน GitHub API

Git เป็นฮาร์ดดิสก์ ตอนที่สอง ฉันว่าฉันจะลงเอยด้วยการเก็บทุกอย่างใน git branches xD

Related Articles