GitHub avatar

Fox's Blog

Repo to VPS: Turning GitHub Actions Into a Free VPS With Persistent Storage

How to turn a GitHub Actions runner into a permanent VPS with git as persistent storage -- tmate, inotify, and commit --amend.

GitHub gives you a free VPS for 6h. I figured out 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 is wiped. The machine is disposable. You install packages, you code, you configure... and poof, at the end it all disappears. As if you'd done nothing.

Unless.

Unless you use git as a hard drive.

And then, suddenly, you have a free VPS with a persistent disk that survives runs. You reconnect, everything is still there. You pick up where you left off.

It's completely broken. Let me explain.


Context: GitHub Actions runners

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

It's made to build your code, run your tests, deploy. The workflow runs, does its job, and the machine is destroyed.

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

The constraint is that those machines are stateless and temporary:

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

So to make a usable VPS out of it, you have to solve two problems:

  1. How to connect to it live?
  2. How to keep the disk between two runs?

That's where it gets into nasty-hack territory.


Problem 1: live SSH with tmate

tmate is a tmux fork 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. Live.

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

# fetch 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 those links are written straight into the repo 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 the truly wild one.


Problem 2: git as a hard drive

Here's the idea that changes everything.

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

On startup, the script restores state from that branch:

filesystem_branch="filesystem"

# fetch the filesystem branch from the 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 -- all in it.

See the idea? The machine is disposable, but the disk lives in git. You relaunch the workflow, the disk is restored, you pick up exactly where you were.

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

First launch: creating the empty disk

On the very first run, the filesystem branch doesn't exist yet. You have to 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 -- like starting from an empty repo.

Why orphan? Because you do NOT want your persistent disk dragging along your whole source code history. The disk stays isolated, with its own life. It starts pristine.

And the git ls-remote --exit-code at the start is just a clean check: "does the 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 closer look:

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 files:

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

If you cleaned those files, you'd break the active session or lose your cache. So we spare them during reset.

A detail that looks trivial at first glance, but without it everything blows up.


Autosave: inotify watching everything

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

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

The magic tool is inotifywait (from the inotify-tools package). It watches the filesystem at kernel level and fires as soon as 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 many changes at once
  done
}

autosave &

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

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

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

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

Without that exclude, you'd end up with an autosave firing in a loop on its own changes. The .git in the list is THE line stopping you from shooting yourself in the foot.

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

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

You literally have an automatic full-disk backup system. Broken.

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 several filesystem events in a burst (temp file creation, rename, old file deletion...). Without debounce, you'd trigger 3-4 commits for a single save.

The sleep 1 says: "wait a second after a save, let the burst settle, before listening again". That groups close-together changes into one commit. Clever.

Plus a periodic save

In case inotify misses an event, 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 disk state.


The clever detail: a single commit

If you commit on every file change, you accumulate thousands of commits. In an hour-long session, your git history explodes. The repo gets huge. It's disgusting.

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

commit_and_push() {
  (
    flock -n 200 || return   # lock so two saves never run at once

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

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: replace 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 must avoid them 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 a case you don't think about at first: what if you launch TWO runs at once? Or if a session modifies the filesystem branch while another is running?

The script handles that 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) matters: it means "merge ONLY if we can advance cleanly, without creating a merge commit".

If both branches diverged (say, two sessions edited different files), the fast-forward fails silently (2>/dev/null || true) and we keep local state. It's not a perfect merge system, but it avoids corruption in the simple case where a single session runs.

Honestly, don't launch 3 sessions in parallel on the same repo. But the code still tries not to blow up if it happens. That's defense.


The APT cache: installing fast

There's a detail in the workflow that looks unremarkable 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.

First run, it downloads and installs. On later runs, it's restored from the GitHub Actions cache -- faster, no re-downloading.

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

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


Scripts hidden in /tmp

Another sneaky but clever detail. At the very 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 into /tmp BEFORE touching the filesystem branch.

Why? Because when you do the git reset --hard to the filesystem branch (empty at first, or holding 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 on every tmate relaunch). 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 that, you spend 30 minutes wondering why your script vanished. I thought of it.


The custom shell

Small comfort: the session gives you a configured shell, not a bare bash.

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

And that .bashrc holds a colored prompt, aliases (ll, lla, rm -i), and above all an 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 tmate processes before closing. That avoids leaving zombie tmate sessions.

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 relaunching itself

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

Except here, tmate sits 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 on its own. You reconnect with the same link.

It's dumb, but it makes the session usable.


Reconnecting in one command

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

tmate's SSH address is written into a host.conf file, committed in the filesystem branch:

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

And since that file is in git, you can fetch 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. Even if the address changed between sessions.


The full flow

Let's recap:

  1. You trigger the workflow (push or manual button)
  2. GitHub hands 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 the SSH/web links
  7. The links are written into the README + host.conf
  8. You connect with ssh or the web terminal
  9. You do whatever you want -- every file change = autosave
  10. 6h later, GitHub kills the VM
  11. Your disk is intact in the "filesystem" branch
  12. You relaunch the workflow → back to step 3, everything is still there

A free VPS with persistent disk. Just with git and GitHub Actions.


OK, let's be honest: the limits

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

  • 6h max per run. You have to relaunch the workflow regularly. Uptime limited per run.
  • For dev and tinkering. You're not hosting your site on this. It's for exploring, dev, debug, testing a throwaway-but-recoverable Linux environment.
  • GitHub sees everything. It's their machines. Don't put anything sensitive on it.
  • Keep the repo private. You're exposing an SSH shell. A public repo = anyone could potentially connect. Bad idea.
  • It's at the edge of the terms of use. GitHub Actions is made for CI/CD, not free VPS. So use sparingly, for legit stuff, without abusing.

The real Achilles heel: git hates big files

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 big binary files badly (a 2GB Docker image in git? forget it)
  • has a 100MB per-file hard limit on GitHub (it just won't push beyond)
  • recommends staying under ~5GB per repo

So if you npm install a project with 500MB of node_modules, or build a project generating heavy binaries, the push to filesystem will either crawl or flat-out fail.

git commit --amend helps (one commit, no growing history), but it changes nothing about a 200MB file never passing through.

Basically: it works great for code, configs, small files. It doesn't work for storing big data or build artifacts. 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 whole system.

If you apt install htop, the binary lands in /usr/bin/htop, which is OUTSIDE the workspace. So it will NOT be saved. Next run, you have to reinstall it.

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

If you want your installs to survive, put them in the workspace (like installing into a local folder instead of system-wide). It's a discipline to pick up.


Free VPS vs real VPS: the matchup

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

The interesting bit 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 make it a hacker toy, not a real server.

For learning, testing, debugging a Linux system quickly 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 awesome.


The pattern behind it all

If you step back, repo-to-vps and the email bot (my other article) rest on the same idea:

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

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

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

Same pattern, two scales. One value on one side, one disk on the other.

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

It's not made for that. But it works. And it's free.


3 key points:

  1. A git branch = a persistent hard drive -- Store your filesystem in a dedicated branch, restore on startup, and you have state surviving disposable machines.

  2. inotify + git = real-time autosave -- inotifywait watches changes at 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 onto 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'll end up storing everything in git branches.

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 paquets, 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.


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.

La contrainte, 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à l'idée qui change tout.

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 l'idée ? 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

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 reste isolé, avec 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 fichiers :

  • .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 paquet, 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 événement, 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 cas 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 fichiers 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 la session 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. Uptime limité à chaque run.
  • Réservé au dev et à l'exploration. Tu vas pas héberger ton site là-dessus. C'est pour explorer, dev, debug, tester un environnement 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 projet qui génère 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 point intéressant 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 système 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 points à 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

把 GitHub Actions 变成带持久存储的免费 VPS:repo-to-vps

怎么把 GitHub Actions runner 变成带 git 持久存储的永久 VPS,tmate、inotify 和 commit --amend。

GitHub 送你 6 小时免费 VPS。我找到了让它永久的办法。

GitHub Actions 送免费 Linux 机器。

字面意义的真 Ubuntu 服务器。2 核,7G 内存,14G 磁盘。免费。每次 6 小时。

唯一"问题":run 一结束全抹掉。机器一次性的。你装包、写代码、配环境……完事啪,全没。就当没干过。

除非。

除非你拿 git 当硬盘。

突然你就有了带持久盘的免费 VPS,run 之间还活着。重连,全在。接着上次的干。

完全离谱。听我讲。


背景:GitHub Actions runner

跑 GitHub Actions 工作流,GitHub 给你一台 VM。

本来是给你构建、跑测试、部署的。工作流干完活,机器销毁。

但没人拦你拿这台 VM 干别的。比如开 SSH shell 当服务器用。

约束是这些机器无状态且临时:

  • 临时:每次最多 6 小时(GitHub 上限 timeout-minutes: 360)
  • 无状态:结束全抹

所以拿它当 VPS 用,要解两个问题:

  1. 怎么实时连上去?
  2. 两次运行之间盘怎么留住?

这就变成脏 hack 了。


问题 1:tmate 实时 SSH

tmate 是 tmux 的 fork,开可分享的 SSH session。

一台机器上启动,给你两个链接:

拿链接连,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"

# 从远端取 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 分支就是你的硬盘。文件、安装、配置,全在里面。

get 到思路了吗?机器一次性的,盘活在 git 里。工作流重跑,盘恢复,接着上次的干。

跟会休眠的 VPS 一样。只不过休眠体是 git 仓库。

第一次启动:建空盘

第一次跑,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 没跟踪的全删了。一般很暴力,把 workspace 洗到底。

但 -e(exclude)保住几个文件:

  • .apt-cache → APT 包缓存(后面讲,贼得很)
  • .cache → 通用缓存
  • host.conf → 本次 session 的 SSH 地址
  • tmate.sock → 本次 tmate 的 socket

这些清掉了,活跃 session 崩了,缓存丢了。所以 reset 时放过它们。

看着是小事,没它全炸。


自动保存:inotify 盯着一切

行,文件怎么进 filesystem 分支?

答案: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 → 当然,不然每次提交触发 autosave,autosave 触发提交……无限循环。灾难。
  • .apt-cache 和 .cache → 缓存,老变,不想往 git 里 spam
  • host.conf 和 tmate.sock → session 文件,一直动
  • .gitignore、.txt.swp → 临时文件(.swp 是 vim 编辑文件)

没这个 exclude,autosave 在自己的产出上无限自激。.git 那条就是不让你朝自己脚开枪的那条。

改个文件?inotify 瞬间发现,提交,推送。不到一秒,改动进 filesystem 分支了。

装个包,写个代码,改个配置,全实时自动保存,什么都不用干。

字面意义的全盘自动保存。离谱。

防抖:别 spam git

每次保存后的 sleep 1 是防抖。

编辑器存文件经常一次产一串文件系统事件(建临时文件、重命名、删旧的……)。没防抖,一次保存触发三四次提交。

sleep 1 的意思是:"保存完等一秒,让这波过去,再继续听"。把靠得近的改动并成一次提交。贼。

再加定期保存

怕 inotify 漏事件,再加个 5 秒一次的保存:

periodic_save() {
  while true; do
    sync_from_remote   # 顺便收远端可能的变化
    sleep 5
    commit_and_push
  done
}

periodic_save &

双保险。盘的状态丢不起。


贼的细节:永远只有一个提交

文件一变就提交,一小时 session 下来几千个提交。git 历史爆炸。仓库变巨。恶心。

解法优雅: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    # AMEND:替换上个提交
      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:多 session 怎么办

有个一开始想不到的情况:同时开两个 run 呢?或者一个 session 跑着,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(只许 fast-forward)很重要:意思是"只能干净往前走,不建合并提交"。

两边分叉了(比如两个 session 各改了文件),fast-forward 静默失败(2>/dev/null || true),保留本地状态。不是完美合并,但在只有一个 session 跑的常见情况不 corrupt。算防守。

说实话,别在同一仓库并行开 3 个 session。但代码尽量做到万一这样也不炸。防守。


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 文件就从 workspace 消失了。

但 session 里还要用(每次 tmate 重启更新 README)。所以先藏 /tmp,git 够不着:

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

想不到这层,卡半小时想脚本去哪了。我想到了。


定制 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 "$@"
}

# Ctrl+D 和 exit 一样
bind -x '"\C-d": "exit"'

敲 exit(或 Ctrl+D),先干净杀 tmate 进程再关。不断僵尸 tmate session。

还有个 tmate-detach,想断开不断 session 时用(下次再连回来)。舒服的细节,但看得出用心。


tmate 自己重启自己

小舒服:shell 里敲 exit,tmate session 一般死了,人就被踢了。

但这里 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 了?session 自己重启。同一个链接连回来。

蠢,但 session 好用。


一条命令重连

断线后怎么连回来,不用每次翻 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 或网页终端连
  9. 随便干,文件一改就 autosave
  10. 6 小时后 GitHub 杀 VM
  11. 你的盘完好躺在"filesystem"分支
  12. 工作流重跑,回第 3 步,全还在

带持久盘的免费 VPS。就靠 git 加 GitHub Actions。


说实话:局限

是 hack,不是真 VPS。所以:

  • 每次最多 6 小时。 得定期重跑工作流。单次 uptime 到头。
  • 只适合开发和探索。 别拿它 host 网站。探索、开发、调个一次性的 Linux 环境可恢复,行。
  • GitHub 全看得见。 机器是人家的。敏感东西别放。
  • 仓库设私有。 你暴露 SSH shell。公开仓库等于谁都能连。找死。
  • 在服务条款边缘。 GitHub Actions 是做 CI/CD 的,不是免费 VPS。所以省着用,干正事,别滥用。

真正的命门:git 恨大文件

Git 为文本生的,不是文件系统。

持久盘活在 git 分支里。你保存的一切都过 git。而 git:

  • 大二进制管得烂(2G Docker 镜像进 git?忘了吧)
  • GitHub 单文件 100 MB 硬上限(超了推不上去)
  • 单仓库建议 5G 以内

所以 session 里 npm install 出 500M node_modules,或构建出大二进制,推 filesystem 要么慢死,要么直接失败。

git commit --amend 有帮助(永远一个提交,历史不膨胀),但 200M 文件该过不去还是过不去。

结论:代码、配置、小文件无敌。大数据、大构建产物不行。 session 里干什么心里得有数。

不是完整系统快照

另一个要说清的: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 欧/月
Uptime 6 小时,要重跑 24/7
盘 git 分支,小文件 真 SSD,好几个 G
内存 ~7G(大方!) 一般 1-2G
CPU 2-4 核不错 1-2 vCPU
搭建 克隆模板 手动配
持久 只有 workspace 整机
合规 条款边缘 100% 没问题

有意思的是裸规格(内存、CPU),GitHub runner 经常强于 5 欧 VPS。但 6 小时 uptime 加只有 workspace 持久,注定它是黑客玩具,不是真服务器。

学东西、测试、快速调个可恢复的 Linux 环境?完美。host 正经东西?买真 VPS。

但要个能随时恢复的临时 Linux 环境?爽到飞起。


背后通用的模式

退一步看,repo-to-vps 和邮件机器人(我另一篇)是同一个思路:

Git 不只是版本管理。它是免费、带版本、有 API 的持久存储。

只要系统无状态(GitHub Actions、Worker、无服务器函数),又要在两次执行之间留状态,git 就能当"盘"。

  • 邮件机器人把 lastId 存 git tag。
  • repo-to-vps 把整个文件系统存 git 分支。

同一模式,两种规模。一边一个值,一边一块盘。

git commit --amend 加 force-push 是通用手法:永远一个提交代表当前状态,每次覆盖。

本不是干这个的。但能跑。还免费。


三句话总结:

  1. git 分支 = 持久硬盘,把文件系统存专用分支,启动恢复,无状态机器之间状态不断。

  2. inotify + git = 实时自动保存,inotifywait 内核层盯变动,瞬间推 git。git commit --amend 永远保持单个干净提交。

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

git 当硬盘,第二集。我觉得我早晚把什么都塞 git 分支里。

Repo to VPS:GitHub Actionsを永続ストレージ付き無料VPSに変える

GitHub Actionsランナーをgitを永続ストレージにして常設VPSに変える方法。tmate、inotify、commit --amend。

GitHubは6時間の無料VPSをくれる。常設にする方法を見つけた。

GitHub Actionsは無料のLinuxマシンをくれる。

つまり本物のUbuntuサーバーだ。2コア、RAM 7GB、ディスク14GBだ。無料だ。1run 6時間だ。

唯一の「問題」:run終了で全部消えることだ。マシンは使い捨てだ。パッケージ入れて、コード書いて、設定して。ぱっと最後に全部消える。何もしなかったみたいにね。

ただし。

gitをハードディスクにするなら別だ。

そうすれば突然、runを生き延びる永続ディスク付きの無料VPSになる。再接続すれば全部残ってる。中断したところから続けられる。

完全にどうかしてる。説明させて。


背景:GitHub Actionsランナー

GitHub Actionsワークフローを走らせると、GitHubはVMをくれる。

コードをbuildして、テスト走らせて、deployするためのものだ。ワークフローが回って、仕事して、マシンは破棄される。

でもこのVMで他のことをするのを止めるものは何もない。たとえばSSHシェルを開いてサーバーとして使うとかね。

制約は、このマシンがステートレスで一時的なことだ。

  • 一時的:1run最大6時間(timeout-minutes: 360、GitHub上限)
  • ステートレス:最後に全部消える

だから使えるVPSにするには2問題を解かなきゃいけない。

  1. リアルタイムでどう接続するか。
  2. run間でディスクをどう保つか。

ここから汚いハックになる。


問題1:tmateによる live SSH

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

マシンで起動すると2リンクを生成する。

どちらかのリンクで接続すれば、はいマシン内のシェルだ。リアルタイムでね。

ワークフローは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入りだ。

1つ目の問題解決だ。でも本当にすごいのは2つ目だ。


問題2:gitをハードディスクにする

全部変える発想がこれだ。

マシンは各runで消える。だからファイルシステムを専用gitブランチに保存する。filesystemって名前だ。

起動時、スクリプトはこのブランチから状態を戻す。

filesystem_branch="filesystem"

# remoteから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リポジトリなだけでね。

初回起動:空ディスクを作る

最初の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ブランチは履歴なしのブランチだ。空リポジトリから出直すみたいなものだ。

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管理外を全部消す。普通は荒い。ワークスペースを徹底掃除する。

でも-e(除外)が一部ファイルを守る。

  • .apt-cache → APTパッケージキャッシュ(後で触れる。賢い)
  • .cache → 汎用キャッシュ
  • host.conf → セッションのSSHアドレス
  • tmate.sock → 進行中tmateセッションのソケット

ここ掃除したら、活性セッションを壊すかキャッシュを失う。だからリセット中は見逃す。

一見どうでもいい詳細だけど、これなしでは全部壊れる。


自動保存:全部見るinotify

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

答え:全ファイル変更を見て自動コミットや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 → 当たり前。しないと各コミットが自動保存を起こしてコミットを起こして。無限ループだ。大惨事だ。
  • .apt-cacheと.cache → キャッシュ。常に変わってgitに垂れ流したくない
  • host.confとtmate.sock → セッションファイル。止まらず動く
  • .gitignore、.txt.swp → 一時ファイル(.swpはvim編集ファイル)

この除外なしでは、自分の変化にループ発火する自動保存になる。リスト内の.gitが、自分の足を撃たないための行だ。

ファイル変えた?inotifyが即検出して、コミットして、pushする。1秒未満で変化がfilesystemブランチに入る。

パッケージ入れる、コード書く、設定触る。全部リアルタイム自動保存だ。何もしなくてもね。

文字通りディスク全体の自動保存システムだ。どうかしてる。

デバウンス:gitを荒らさない

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

エディターでファイルを保存すると、たいてい連発で複数ファイルシステムイベントが出る(一時ファイル作成、改名、旧削除など)。デバウンスなしでは1保存で3、4コミットが起こる。

sleep 1はこう言う。「保存後に1秒待って、連発が落ち着くまで次の監視を待つ」ってね。近い変化を1コミットにまとめる。賢い。

保険に定期的保存も

inotifyがイベントを取りこぼした場合に備えて、5秒ごとの保存もある。

periodic_save() {
  while true; do
    sync_from_remote   # 遠隔変化があれば取る
    sleep 5
    commit_and_push
  done
}

periodic_save &

ベルトと吊りバンドだ。ディスク状態は絶対失いたくない。


賢い詳細:コミット1つだけ

ファイル変化ごとにコミットしたら、何千コミットも溜まる。1時間セッションでgit履歴が爆発する。リポジトリが巨大になる。汚い。

解は優雅だ。新規作成じゃなく既存コミットを amend するんだ。

commit_and_push() {
  (
    flock -n 200 || return   # 2つの保存が同時回転しないようロック

    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:直前コミットを上書き
      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ブランチは常にコミット1つだ。何回保存してもね。現状態のスナップショット1つを何度も強制pushするだけだ。

flockは錠だ。保存ループが2つあるから(inotifyと定期的)、git同時起動で踏み合わないようにする。gitプロセスは一度に1つだ。

きれいだ。


sync_from_remote:複数セッションをさばく

最初は思いつかないケースがある。2 run同時起動したらどうなるの?あるセッションが別動作中に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(fast-forwardのみ)が大事だ。「きれいに進められるときだけマージして、マージコミットは作らない」って意味だ。

両ブランチが分岐してたら(たとえば2セッションが違うファイルを変えたら)、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パッケージをキャッシュするアクション経由で入る。

初回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へコピーされる。

なぜか。filesystemブランチ(最初は空か、ディスク入り)へのgit reset --hardをやると、ソースリポジトリの.github/scriptsファイルはワークスペースから消えるからだ。

でもスクリプトはセッション中まだ要る(tmate再起動ごとにREADME更新するため)。だからgitの届かない/tmpに隠す。

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は色付きプロンプトとエイリアス(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プロセスをきれいに殺す。ゾンビtmateセッションを残さない。

tmate-detach関数もある。セッション殺さず切断したいとき用だ(後で再接続するため)。快適詳細だけど、手入れ水準を示す。


1人で再起動する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する?セッションは1人で再起動する。同じリンクで再接続する。

ばかみたいだけど、セッションを使えるようにする。


1コマンド再接続

ログを漁らず切断後にどう再接続するの?

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秒ごとに保険コミットする
  6. tmateが起動してSSHやウェブリンクを生成する
  7. リンクはREADMEとhost.confに書かれる
  8. sshかウェブターミナルで接続する
  9. 好きにやる。各ファイル変化は自動保存だ
  10. 6時間後、GitHubがVMを殺す
  11. ディスクは「filesystem」ブランチで無事だ
  12. ワークフロー再起動→手順3に戻る。全部残ってる

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


正直に言おう:限界

ハックだ。本物VPSじゃない。だからこうだ。

  • 1run最大6時間。 ワークフロー定期再起動が必要だ。各runで稼働制限だ。
  • 開発と探索用だ。 サイトをホストしない。探索、開発、デバッグ、使い捨てだけど戻せるLinux環境試し用だ。
  • GitHubは全部見る。 向こうのマシンだ。繊細なものは置かない。
  • リポジトリは非公開に保つ。 SSHシェルを出してる。公開リポジトリは誰でも接続できる可能性がある。悪い考えだ。
  • 利用条件の際だ。 GitHub ActionsはCI/CD用で、無料VPS用じゃない。だから控えめに、正当に、乱用せず使う。

本当のアキレス腱:gitは大きいファイル嫌い

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

永続ディスクはgitブランチで生きる。保存するものは全部gitを通る。gitはこうだ。

  • 大きいバイナリが苦手だ(gitに2GBのDockerイメージ?忘れて)
  • GitHubではファイル100MB上限だ(hard limit、超えたらpushできない)
  • リポジトリ約5GB未満推奨だ

だから500MBのnode_modules付きプロジェクトをnpm installしたり、重いバイナリ作るプロジェクトをbuildしたりしたら、filesystemへのpushは死ぬほど遅いか完全に失敗する。

git commit --amendは助ける(コミット1つ、膨らむ履歴なし)。でも200MBファイルが通らない事実は変わらない。

ざっくり、コードも設定も小さいファイルも最高に動く。大きいデータやバイナリ成果物の保存は動かない。 セッションでやることに気をつける。

完全なシステムスナップショットじゃない

大事なニュアンスもう1つ:filesystemブランチが保存するのはワークスペース(リポジトリフォルダ)であって、システム全体じゃない。

apt install htopしたら、バイナリはワークスペース外の/usr/bin/htopに行く。だから保存されない。次runで入れ直しだ。

だからAPTキャッシュとprestart.shがある。残るのはワークスペースだけだから、各起動でシステム環境を準備し直すためだ。

インストールを残したければワークスペースに入れる(システムじゃなくローカルフォルダに入れるとか)。組み込む体操だ。


無料VPS対本物VPS:対決

repo-to-vps 本物VPS(月5ユーロ)
価格 0円 月約5-10ユーロ
稼働 6時間、再起動要 24時間365日
ディスク gitブランチ、小さいファイル 本物SSD、数GB
RAM 約7GB(太っ腹!) 1-2GBが多い
CPU 2-4コアまとも 1-2 vCPU
立ち上げ テンプレをクローン 手動設定
永続 ワークスペースのみ 完全システム
正当性 利用条件の際 100%きれい

面白いのは、素 specs(RAM、CPU)ではGitHubランナーが5ユーロVPSより良いことが多いってことだ。でも6時間稼働とワークスペース限定永続が、ハッカーおもちゃにして本物サーバーにしないものだ。

学ぶ、試す、Linuxシステムを戻せる環境でさっとデバッグする?最適だ。真面目なものをホストする?本物VPSを取って。

でも好きなときに復元できる一時Linux環境には?ただ最高だ。


全部裏にあるパターン

引いて見ると、repo-to-vpsとメールボット(別記事)は同じ考えに立つ。

gitはバージョン管理だけじゃない。永続で無料でバージョン付きでAPI経由のストレージシステムだ。

ステートレス機構(GitHub Actions、Worker、サーバーレス関数)があって、2実行間で状態を保ちたいとき、gitは「ディスク」になれる。

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

同じパターン、違う規模だ。片方は値1つ、もう片方はディスク1つだ。

git commit --amendと強制pushが共通技術だ。現状態を表す単一コミットを保って、各更新で上書きするんだ。

そのためじゃない。でも動く。無料だ。


覚えるべき3点:

  1. gitブランチ1つは永続ハードディスクだ。ファイルシステムを専用ブランチに保存して、起動時に戻せば、使い捨てマシンを生き延びる状態になる。

  2. inotifyとgitでリアルタイム自動保存。inotifywaitがカーネル水準で変化を見てgitへ即pushする。git commit --amendできれいな単一コミットを保つ。

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

gitをハードディスクに、第2話だ。gitブランチに全部保存して終わりそうだ。

Repo에서 VPS로: GitHub Actions를 영속 저장소가 있는 무료 VPS로 바꾸기

GitHub Actions 러너를 git을 영속 저장소 삼아 영구 VPS로 바꾸는 방법. tmate와 inotify, commit과 amend.

GitHub가 6시간짜리 무료 VPS를 줘요. 영구로 만드는 법을 찾았어요.

GitHub Actions가 무료 Linux 기기를 줘요.

말 그대로 진짜 Ubuntu 서버예요. 코어 2개, RAM 7GB, 디스크 14GB. 무료. 실행당 6시간.

유일한 "문제"는 실행이 끝나면 전부 지워져요. 기기가 일회용이에요. 패키지를 설치하고, 코딩하고, 설정하고... 팡 하고 끝나면 전부 사라져요. 아무것도 안 한 것처럼요.

만약에요.

git을 하드디스크로 쓰면요.

그러면 갑자기 실행을 살아남는 영속 디스크가 있는 무료 VPS가 생겨요. 다시 접속해도 전부 그대로 있어요. 멈췄던 곳에서 이어가요.

완전히 맛이 갔어요. 설명해 드릴게요.


배경: GitHub Actions 러너

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

코드를 빌드하고 테스트를 돌리고 배포하라고 있는 거예요. 워크플로가 돌고 일을 하고 기기가 없어져요.

하지만 이 VM으로 다른 일을 하지 못할 이유는 없어요. 예를 들어 SSH 셸을 열고 서버처럼 쓰는 것.

제약은 이 기기들이 무상태에 임시라는 거예요.

  • 임시: 실행당 최대 6시간 (timeout-minutes: 360, GitHub 상한)
  • 무상태: 끝나면 전부 지워져요

그래서 쓸 만한 VPS로 만들려면 두 문제를 풀어야 해요.

  1. 실시간으로 그 위에 어떻게 접속하지요?
  2. 두 실행 사이에 디스크를 어떻게 유지하지요?

여기에서 지저분한 핵이 돼요.


문제 1: tmate로 라이브 SSH

tmate는 공유 가능한 SSH 세션을 만드는 tmux 포크예요.

기기에서 실행하면 링크 둘을 만들어줘요.

이 링크 중 하나로 접속하면 쾅, 실시간으로 기기 속 셸에 있어요.

그래서 워크플로가 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

모든 것을 바꾸는 생각은 바로 여기예요.

기기는 매 실행마다 지워져요. 그래서 파일시스템을 filesystem이라는 전용 git 브랜치에 저장해요.

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

filesystem_branch="filesystem"

# remote에서 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 저장소예요.

첫 실행: 빈 디스크 만들기

맨 처음 실행에는 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이 추적하지 않는 모든 것을 날려요. 보통은 거칠어요. 워크스페이스를 말끔히 청소해요.

하지만 -e (제외)가 특정 파일들을 지켜줘요.

  • .apt-cache → APT 패키지 캐시 (뒤에 나와요. 영리해요)
  • .cache → 범용 캐시
  • host.conf → 세션 SSH 주소
  • tmate.sock → 진행 중인 tmate 세션 소켓

이 파일들을 청소하면 활성 세션이 깨지거나 캐시를 잃어요. 그래서 리셋 동안 살려둬요.

언뜻 바보 같은 세부 사항이지만, 없으면 전부 터져요.


자동 저장: 모든 것을 감시하는 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 플래그를 뜯어봐요. 각자 중요해서요.

  • -r → 재귀, 모든 하위 폴더 감시
  • -e modify,create,delete,move → 이 4가지 이벤트 종류에 반응
  • --exclude '...' → 특정 파일을 무시하는 정규식

--exclude가 중요해요. 무시하는 것을 보세요.

  • .git → 당연하죠. 아니면 매 커밋이 자동 저장을 일으키고 자동 저장이 커밋을 일으켜요... 무한 루프. 재앙.
  • .apt-cache와 .cache → 캐시. 계속 바뀌고 git에 스팸하고 싶지 않아요
  • host.conf와 tmate.sock → 계속 움직이는 세션 파일
  • .gitignore, .txt.swp → 임시 파일 (.swp는 vim 편집 파일)

이 제외가 없으면 자기 변경에 루프로 일으키는 자동 저장이 돼요. 목록의 .git이 바로 자기 발에 총 쏘는 것을 막는 줄이에요.

파일을 고치면요? inotify가 바로 감지하고, 커밋하고, 푸시해요. 1초 미만으로 변경이 filesystem 브랜치에 들어가요.

패키지를 설치하고, 코드를 쓰고, 설정을 만져요. 전부 실시간 자동 저장. 손댈 것 없이요.

말 그대로 디스크 전체 자동 저장 시스템이 있어요. 맛이 갔어요.

디바운스: git 스팸 금지

매 저장 뒤 sleep 1은 디바운스예요.

에디터에서 파일을 저장하면 흔히 파일시스템 이벤트 여러 개가 한꺼번에 일어나요 (임시 파일 만들기, 이름 바꾸기, 옛것 지우기...). 디바운스가 없으면 저장 한 번에 커밋 3~4개가 일어나요.

sleep 1이 말해요. "저장 뒤 1초 기다려. 한꺼번이가 가라앉을 시간을 줘. 다시 듣기 전에." 가까운 변경들을 커밋 하나로 묶어요. 영리해요.

덤으로 주기 저장

inotify가 이벤트를 놓칠 경우를 대비해 5초마다 저장도 있어요.

periodic_save() {
  while true; do
    sync_from_remote   # 혹시 모를 원격 변경 가져오기
    sleep 5
    commit_and_push
  done
}

periodic_save &

안전벨트와 멜빵. 디스크 상태를 절대 잃고 싶지 않아요.


영리한 세부 사항: 커밋 하나

파일이 바뀔 때마다 커밋하면 수천 개 커밋이 쌓여요. 세션 한 시간에 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 브랜치는 항상 커밋 하나예요. 저장을 몇 번 하든요. 현재 상태 스냅샷 하나를 계속 강제 푸시할 뿐이에요.

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(fast-forward만)가 중요해요. "깔끔히 앞으로만 갈 수 있을 때 합쳐"라는 뜻이에요. 합치기 커밋을 만들지 않아요.

두 브랜치가 갈라졌으면 (예를 들어 두 세션이 서로 다른 파일을 고쳤으면) fast-forward가 조용히 실패하고(2>/dev/null || true) 로컬 상태를 유지해요. 완벽한 합치기 시스템은 아니지만, 세션 하나가 도는 단순한 경우에 깨지지 않게 해요.

솔직히 같은 저장소에 병렬 세션 셋을 띄우면 안 돼요. 그래도 혹시 일어나면 터지지 않으려고 코드가 애써요. 방어예요.


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에 복사돼요.

왜요? filesystem 브랜치로 git reset --hard를 하면 (처음에는 비었거나 여러분 디스크가 들어 있어요) 소스 저장소의 .github/scripts 파일들이 워크스페이스에서 사라지니까요.

하지만 스크립트는 세션 동안 여전히 필요해요 (tmate가 다시 실행될 때마다 README를 고치려고). 그래서 git 손이 닿지 않는 /tmp에 숨겨요.

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에는 색 프롬프트와 별칭(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 프로세스를 깔끔히 죽여요. 좀비 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 주소는 filesystem 브랜치에 커밋된 host.conf 파일에 쓰여요.

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. 워크플로를 일으켜요 (푸시 또는 손 버튼)
  2. GitHub가 Ubuntu VM 하나를 줘요
  3. 스크립트가 "filesystem" 브랜치에서 디스크를 복원해요
  4. inotify가 모든 변경 감시를 시작해요
  5. periodic_save가 백업으로 5초마다 커밋해요
  6. tmate가 시작돼 SSH와 웹 링크를 만들어요
  7. 링크가 README + host.conf에 쓰여요
  8. ssh나 웹 터미널로 접속해요
  9. 원하는 것을 해요. 파일 변경마다 자동 저장
  10. 6시간 뒤 GitHub가 VM을 죽여요
  11. 디스크는 "filesystem" 브랜치에 온전해요
  12. 워크플로를 다시 실행해요 → 3단계로 돌아가고 전부 그대로 있어요

git와 GitHub Actions만으로 영속 디스크가 있는 무료 VPS.


좋아요, 솔직해져요. 한계

핵이지 진짜 VPS가 아니에요. 그래서요.

  • 실행당 최대 6시간. 워크플로를 자주 다시 실행해야 해요. 매 실행마다 가동 시간이 제한돼요.
  • 개발과 탐험용. 사이트를 그 위에 호스팅하지 않아요. 일회용이지만 되살릴 수 있는 Linux 환경에서 탐험하고 개발하고 디버깅하려고요.
  • GitHub가 전부 봐요. 자기들 기기예요. 민감한 것 두지 마세요.
  • 저장소는 비공개로 두세요. SSH 셸을 노출해요. 공개 저장소 = 누구나 접속할 가능성. 나쁜 생각.
  • 이용 조건 경계에 있어요. GitHub Actions는 CI와 CD용이지 무료 VPS용이 아니에요. 정당하게, 남용 없이, 아껴 쓰세요.

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

Git은 텍스트용이지 파일시스템용이 아니에요.

영속 디스크는 git 브랜치에 살아요. 그래서 저장하는 모든 것이 git을 거쳐요. 그리고 git은요.

  • 큰 바이너리 파일을 못 다뤄요 (2GB Docker 이미지를 git에요? 잊어요)
  • GitHub 파일당 100MB 한도 (하드 한도, 그 위는 푸시가 안 돼요)
  • 저장소당 ~5GB 이하 권장

그래서 500MB node_modules 프로젝트를 npm install하거나 무거운 바이너리를 만드는 프로젝트를 빌드하면 filesystem 푸시가 죽도록 느려지거나 아예 실패해요.

git commit --amend가 도와줘요 (커밋 하나, 부푸는 히스토리 없어요). 하지만 200MB 파일이 절대 통과하지 못하는 것은 바뀌지 않아요.

대충요. 코드와 설정, 작은 파일에는 최고예요. 큰 자료나 바이너리 산출물 저장에는 안 돼요. 세션에서 하는 일에 이 점을 염두에 둬야 해요.

완전한 시스템 스냅샷이 아니에요

또 다른 중요한 뉘앙스: filesystem 브랜치는 워크스페이스(저장소 폴더)를 저장하지 전체 시스템을 저장하지 않아요.

apt install htop을 하면 바이너리가 워크스페이스 밖 /usr/bin/htop에 가요. 그래서 저장되지 않아요. 다음 실행에 다시 설치해야 해요.

그래서 APT 캐시와 prestart.sh가 있어요. 워크스페이스만 유지되니 매 시작 때 시스템 환경을 다시 준비하려고요.

설치를 살리고 싶으면 워크스페이스에 넣어야 해요 (예를 들어 시스템이 아니라 로컬 폴더에 설치). 익혀야 하는 체조예요.


무료 VPS 대 진짜 VPS: 대결

repo-to-vps 진짜 VPS (월 5유로)
가격 0유로 ~월 5-10유로
가동 시간 6시간, 다시 실행 24시간
디스크 git 브랜치, 작은 파일 진짜 SSD, 수 GB
RAM ~7 GB (후해요!) 흔히 1-2 GB
CPU 괜찮은 코어 2-4개 vCPU 1-2개
설정 템플릿 복제 손 설정
영속 워크스페이스만 전체 시스템
정당성 이용 조건 경계 100퍼센트 깨끗

흥미로운 지점은 날것 스펙(RAM, CPU)으로는 GitHub 러너가 흔히 5유로 VPS보다 낫다는 거예요. 하지만 6시간 가동 시간과 워크스페이스 한정 영속이 해커 장난감으로 만드는 것이지 진짜 서버가 아니에요.

배우고 테스트하고 일회용이지만 되살릴 수 있는 Linux 환경에서 빨리 시스템 하나 디버깅하려고요? 완벽. 진지한 무엇을 호스팅하려고요? 진짜 VPS를 사세요.

하지만 마음대로 복원할 수 있는 임시 Linux 환경으로는요? 그냥 genial해요.


그 뒤 패턴

뒤로 물러나 보면 repo-to-vps와 이메일 봇(제 다른 글)은 같은 생각에 기대요.

Git은 버전 관리자만이 아니에요. 무료이고 버전이 있으며 API로 닿는 영속 저장 시스템이에요.

무상태 시스템(GitHub Actions, Worker, 서버리스 함수)이 있고 두 실행 사이에 상태를 유지하고 싶어지면 git이 "디스크"가 될 수 있어요.

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

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

그리고 git commit --amend와 강제 푸시가 공통 기법이에요. 현재 상태를 나타내는 커밋 하나를 유지하고 매 고치기마다 덮어써요.

그렇게 쓰라고 만든 것이 아니에요. 하지만 동작해요. 그리고 무료예요.


기억할 3가지:

  1. git 브랜치 하나 = 영속 하드디스크 하나: 파일시스템을 전용 브랜치에 저장하고 시작할 때 복원하면 일회용 기기를 살아남는 상태가 생겨요.

  2. inotify + git = 실시간 자동 저장: inotifywait가 커널 수준에서 변경을 감시해서 git에 바로 푸시해요. 깔끔한 커밋 하나로 유지하는 git commit --amend와 함께요.

  3. tmate가 러너를 VPS로 바꿔요: GitHub Actions 기기의 라이브 SSH. 자동 재시작과 GitHub API 경유 명령 하나 다시 접속.

하드디스크로서의 Git, 두 번째 편. 결국 전부 git 브랜치에 저장하게 될 것 같아요

Repo to VPS: kalıcı depolamayla GitHub Actions'ı ücretsiz VPS'e dönüştürmek

GitHub Actions koşucusunu git kalıcı depolamayla kalıcı VPS'e dönüştürmek, tmate, inotify ve commit --amend.

GitHub sana 6 saatliğine ücretsiz bir VPS veriyor. Onu kalıcı yapmanın 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 saatliğine.

Tek "sorun": run sonunda her şey silinir. Makine atılabilirdir. Paket kurarsın, kod yazarsın, yapılandırırsın... ve puf, sonda her şey kaybolur. Sanki hiçbir şey yapmamışsın gibi.

Şöyle olmasa.

Giti sabit disk olarak kullansan şöyle olmasa.

Ve bir anda runlar arasında hayatta kalan kalıcı diskli ücretsiz bir VPSin olur. Yeniden bağlanırsın, her şey hâlâ oradadır. Kaldığın yerden devam edersin.

Bu tamamen kırık. Açıklayayım.


Bağlam: GitHub Actions koşucuları

Bir GitHub Actions workflowu çalıştırdığında GitHub sana bir VM verir.

Kodunu derlemek, testlerini çalıştırmak, dağıtmak için yapılmış. Workflow döner, işini yapar ve makine yok edilir.

Ama bu VM ile başka bir şey yapmana hiçbir şey engel değil. Mesela üzerinde bir SSH kabuğu açıp sunucu olarak kullanmak gibi.

Kısıt şu ki bu makineler stateless ve geçicidir:

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

O yüzden kullanılabilir bir VPS yapmak için iki sorunu çözmek gerekir:

  1. Üzerine gerçek zamanlı nasıl bağlanılır?
  2. Disk iki run arasında nasıl tutulur?

Burası pis bir hacke dönüşüyor.


Sorun 1: tmate ile canlı SSH

tmate, paylaşılabilir bir SSH oturumu oluşturan bir tmux çatalıdır.

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

Bu bağlantılardan biriyle bağlanırsın ve bum, makinede bir kabuktesin. Gerçek zamanlı.

Workflow o yüzden tmate çalıştırı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

# bağlantıları al
tmate_ssh=$(tmate -S /tmp/tmate.sock display -p '#{tmate_ssh}')
tmate_web=$(tmate -S /tmp/tmate.sock display -p '#{tmate_web}')

Ve bu bağlantılar bir Python scriptiyle doğrudan deponun READMEsi içine yazılır. Deponu açarsın, bağlantıyı görürsün, tıklarsın. İşte VPSindesin.

İlk sorun çözüldü. Ama gerçekten çılgın olan ikincisi.


Sorun 2: sabit disk olarak git

Her şeyi değiştiren fikir işte bu.

Makine her run silinir. O yüzden dosya sistemini filesystem adlı adanmış bir git dalında saklıyoruz.

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

filesystem_branch="filesystem"

# filesystem dalını remotedan al
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# çalışma alanını bu daldan geri yükle
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

filesystem dalı senin sabit diskinDİR. Dosyaların, kurulumların, yapılandırmaların, hepsi içindedir.

Fikri görüyor musun? Makine atılabilir ama disk gitte yaşar. Workflowu yeniden çalıştırırsın, disk geri yüklenir, tam kaldığın yerden devam edersin.

Hibernate olan bir VPS gibi. Tek fark hibernasyonun bir git deposu olması.

İlk çalıştırma: boş diski oluşturmak

En ilk run filesystem dalı henüz yoktur. Oluşturmak gerekir. Ve bu önemsiz değildir:

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 kilit noktadır. Öksüz bir dal hiç tarihçesi olmayan bir daldır: sanki boş bir depodan başlıyormuşsun gibi.

Neden öksüz? Çünkü kalıcı diskinin kaynak kodunun tüm tarihçesini sürüklemesini istemezsin. Disk izole kalır, kendi hayatıyla. Tertemiz başlar.

Baştaki git ls-remote --exit-code sadece temiz bir kontroldür: "dal remoteda zaten var mı?". Varsa hiçbir şeye dokunmayız. Yoksa oluştururuz. Sevdiğimiz gibi idempotent.

Seçici git clean: önbellekleri korumak

Bu satır üzerinde durmayı hak ediyor:

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

git clean -fdx git tarafından izlenmeyen HER şeyi siler. Normalde serttir: çalışma alanını kökten temizler.

Ama -e (hariç tut) bazı dosyaları korur:

  • .apt-cache → APT paket önbelleği (buna döneceğiz, kurnazca)
  • .cache → genel önbellek
  • host.conf → oturumun SSH adresi
  • tmate.sock → devam eden tmate oturumunun soketi

Şu dosyaları temizleseydin aktif oturumu bozar ya da önbelleğini kaybederdin. O yüzden sıfırlama sırasında es geçilirler.

İlk bakışta aptalca bir detay ama o olmadan her şey patlar.


Otomatik kayıt: her şeyi izleyen inotify

Peki dosyalar filesystem dalına nasıl girer?

Yanıt: TÜM dosya değişikliklerini izleyip otomatik commit/push yapan bir izleyici.

Sihirli araç inotifywait (inotify-tools paketinden). Dosya sistemini çekirdek seviyesinde izler ve bir dosya değişir değişmez 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   # bir anda çok değişiklik olursa debounce
  done
}

autosave &

inotify bayraklarını tek tek açalım çünkü her biri önemli:

  • -r → özyinelemeli, tüm alt klasörleri izler
  • -e modify,create,delete,move → bu 4 olay türüne tepki verir
  • --exclude '...' → bazı dosyaları yoksaymak için bir regex

--exclude kritiktir. Neleri yoksaydığına bak:

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

Bu hariç tutma olmadan kendi değişikliklerinde döngüde tetiklenen bir autosave ile kalırdın. Listedeki .git, ayağına sıkmanı engelleyen satırdır.

Bir dosyayı mı değiştirdin? inotify anında tespit eder, commitler, pushlar. Bir saniyeden kısa sürede değişikliğin filesystem dalındadır.

Bir paket mi kuruyorsun, kod mu yazıyorsun, yapılandırmaya mı dokunuyorsun: her şey gerçek zamanlı, otomatik, hiçbir şey yapmana gerek kalmadan kaydedilir.

Kelimenin tam anlamıyla tüm diskin otomatik yedekleme sistemine sahipsin. Kırık.

Debounce: giti spamlememek

Her kayıttan sonraki sleep 1 bir debouncetur.

Bir editörde bir dosyayı kaydettiğinde genelde dosya sistemi olayları patlaması üretir (geçici dosya oluşturma, yeniden adlandırma, eskinin silinmesi...). Debounce olmadan tek bir kayıt için 3-4 commit tetiklerdin.

sleep 1 şunu der: "bir kayıttan sonra bir saniye bekle, patlama yatışsın, yeniden dinlemeden önce". Yakın değişiklikleri tek committe gruplar. Kurnazca.

Üstüne periyodik bir kayıt daha

inotify bir olayı kaçırır diye bir de 5 saniyede bir kayıt var:

periodic_save() {
  while true; do
    sync_from_remote   # olası uzak değişiklikleri alır
    sleep 5
    commit_and_push
  done
}

periodic_save &

Hem kemer HEM askı. Diskin durumunu kaybetmek hiç istemeyiz.


Kurnaz detay: tek bir commit

Her dosya değişikliğinde commitlersen binlerce commit biriktirirsin. Bir saatlik oturumda git tarihçen patlar. Depo devasa olur. İğrençtir.

Çözüm zariftir: yeni bir tane oluşturmak yerine mevcut commiti değiştiriyoruz.

commit_and_push() {
  (
    flock -n 200 || return   # iki kaydın aynı anda dönmemesi için kilit

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # scriptlere dokunma

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: önceki commiti ezer
      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 demektir: "son commiti bununla değiştir".

Böylece filesystem dalında HER ZAMAN tek bir commit olur. Kaç kez kaydedersen kaydet. Sadece mevcut durumun bir anlık görüntüsüdür, tekrar tekrar force-pushlanır.

flock bir kilittir: iki kayıt döngüsü olduğu için (inotify + periyodik) gitin aynı anda çalışıp birbirine basmasını engellemek gerekir. Aynı anda tek git süreci.

Temiz.


sync_from_remote: birden çok oturumu yönetmek

İşte başta düşünmediğin bir durum: ya AYNI ANDA İKİ run çalıştırırsan? Ya da bir oturum diğeri dönerken filesystem dalını değiştirirse?

Script bunu her committen önce sync_from_remote ile yönetir:

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

--ff-only (yalnızca fast-forward) önemlidir: "YALNIZCA merge commiti oluşturmadan temiz ilerlenebiliyorsa birleştir" demektir.

İki dal ıraksadıysa (mesela iki oturum farklı dosyaları değiştirdiyse) fast-forward sessizce başarısız olur (2>/dev/null || true) ve yerel durumu koruruz. Mükemmel bir birleştirme sistemi değil ama tek bir oturumun döndüğü basit durumda bozulmaları engeller.

Dürüstçe, aynı depoda paralel 3 oturum çalıştırmamak gerekir. Ama kod yine de o durumda patlamamaya çalışır. Savunmadır bu.


APT önbelleği: hızlı kurmak

Workflowda 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 aksiyonla kurulur.

İlk run indirir ve kurar. Sonraki runlarda GitHub Actions önbelleğinden geri yüklenir: daha hızlı, yeniden indirmeye gerek yok.

Ve az önceki git clean -fdx -e .apt-cachei hatırlıyor musun? Bağlantılı. .apt-cache klasörü temizlikten tam da oturum sırasında kurduğun paketlerin minimum kalıcılığı için korunur.

Her şey birbirine oturur. Yaşam döngüsünün tamamını düşündüm.


/tmp içinde saklanan scriptler

Yine kısır ama kurnaz bir detay. Scriptin 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/"

Scriptler (update_readme.py vs.) filesystem dalına dokunmadan ÖNCE /tmp içine kopyalanır.

Neden? Çünkü filesystem dalına git reset --hard yaptığında (başlangıçta boş ya da diskini içeren) kaynak deponun .github/scripts dosyaları çalışma alanından kaybolur.

Ama script oturum sırasında onlara hâlâ ihtiyaç duyar (tmate her yeniden başladığında READMEyi güncellemek için). O yüzden gitin erişemeyeceği /tmp içine saklar:

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

Bunu düşünmezsen scriptinin neden kaybolduğunu anlamak için 30 dakika debelenirsin. Ben düşündüm.


Ölçüye göre kabuk

Küçük konfor: oturum sana çıplak bir bash değil, yapılandırılmış bir kabuk verir.

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 istem, takma adlar (ll, lla, rm -i) ve hepsinden önemlisi bir exit geçersiz kılma içerir:

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

# Ctrl+D exit ile aynı şeyi yapar
bind -x '"\C-d": "exit"'

exit (ya da Ctrl+D) yazdığında kapanmadan önce tmate süreçlerini temizce öldürür. Zombi tmate oturumları bırakmayı engeller.

Daha sonra yeniden bağlanmak için oturumu öldürMEDEN ayrılmak istersen bir tmate-detach fonksiyonu da var. Konfor detayı ama özen seviyesini gösterir.


Kendi kendine yeniden başlayan tmate

Küçük konfor: kabuğuna exit yazarsan normalde tmate oturumu ölür ve temelli bağlantın kesilir.

Ama burada tmate bir while true döngüsündedir:

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 mi yaptın? Oturum kendi kendine yeniden başlar. Aynı bağlantıyla yeniden bağlanırsın.

Aptalca ama oturumu kullanılabilir kılar.


Tek komutla yeniden bağlanma

Her seferinde run loglarında kazı yapmadan bir kopuş sonrası nasıl yeniden bağlanırsın?

tmate SSH adresi filesystem dalına commitlenen bir host.conf dosyasına yazılır:

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

Ve bu dosya gitte olduğu için onu GitHub API üzerinden 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, depodaki güncel SSH adresini çeker ve bağlanır. Adres iki oturum arasında değişmiş olsa bile.


Tam akış

Özetleyelim:

  1. Workflowu tetiklersin (push ya da manuel düğme)
  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 yedek olarak her 5 saniyede commitler
  6. tmate başlar → SSH/web bağlantılarını üretir
  7. Bağlantılar README + host.conf içine yazılır
  8. ssh ya da web terminaliyle bağlanırsın
  9. İstediğini yaparsın: her dosya değişikliği = otomatik kayıt
  10. 6 saat sonra GitHub VM i öldürür
  11. Diskin "filesystem" dalında bozulmadan durur
  12. Workflowu yeniden çalıştırırsın → 3. adıma dön, her şey hâlâ orada

Kalıcı diskli ücretsiz bir VPS. Sadece git ve GitHub Actions ile.


Tamam, dürüst olalım: limitler

Bu bir hack, gerçek bir VPS değil. O yüzden:

  • Run başına 6 saat maksimum. Workflowu düzenli yeniden başlatmak gerekir. Her run sınırlı çalışma süresi.
  • Dev ve keşfe ayrılmış. Siteni bunun üstünde barındırmayacaksın. Keşfetmek, dev, debug, atılabilir ama kurtarılabilir bir Linux ortamını test etmek içindir.
  • GitHub her şeyi görür. Onların makineleri. Hassas bir şey koyma.
  • Depoyu özel tut. Bir SSH kabuğu sunuyorsun. Herkese açık bir depo = 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. O yüzden ölçülü, meşru şeyler için, abartmadan kullanmalı.

Gerçek Aşil topuğu: git büyük dosyalardan nefret eder

Git metin içindir, dosya sistemi için değil.

Kalıcı disk bir git dalında yaşar. O yüzden kaydettiğin her şey gitten geçer. Ve git:

  • büyük ikili dosyaları kötü yönetir (gitte 2 GB Docker imajı mı? unut)
  • GitHubda dosya başına 100 MB limiti vardır (sert limit, ötesini pushlamaz)
  • depo başına ~5 GB altında kalınması önerilir

O yüzden 500 MB node_modules olan bir proje npm install edersen ya da ağır ikililer üreten bir proje derlersen filesysteme push ya çok kasar ya da düpedüz başarısız olur.

git commit --amend yardımcı olur (tek bir commit, şişen tarihçe yok) ama 200 MB dosyanın asla geçmeyeceği gerçeğini değiştirmez.

Kabaca: kod, yapılandırma, küçük dosyalar için süper çalışır. Büyük verileri ya da ikili artefaktları saklamak için çalışmaz. Oturumunda ne yaptığına dair bunu aklında tutmak gerekir.

Tam bir sistem anlık görüntüsü değil

Bir önemli nüans daha: filesystem dalı çalışma alanını (depo klasörü) kaydeder, tüm sistemi değil.

apt install htop yaparsan ikili /usr/bin/htop içine gider ki bu çalışma alanının DIŞINDADIR. O yüzden kaydedilMEZ. Sonraki run yeniden kurmak gerekir.

APT önbelleği ve prestart.sh bu yüzden var: yalnızca çalışma alanı kalıcı olduğu için sistem ortamını her başlatmada yeniden hazırlamak.

Kurulumlarının hayatta kalmasını istiyorsan onları çalışma alanına koymak gerekir (mesela sistem yerine yerel bir klasöre kurmak). Sindirilmesi gereken bir jimnastik.


Ücretsiz VPS ve gerçek VPS: maç

repo-to-vps Gerçek VPS (5 €/ay)
Fiyat 0 € ~5-10 €/ay
Çalışma süresi 6 saat, yeniden başlatmalı 7/24
Disk git dalı, küçük dosyalar gerçek SSD, birkaç GB
RAM ~7 GB (cömert!) genelde 1-2 GB
CPU düzgün 2-4 çekirdek 1-2 vCPU
Kurulum bir şablonu klonla manuel yapılandırma
Kalıcılık yalnızca çalışma alanı tam sistem
Meşruiyet KG sınırında %100 temiz

İlginç nokta, ham özelliklerde (RAM, CPU) GitHub koşucusunun 5 €luk bir VPS ten genelde DAHA İYİ olmasıdır. Ama 6 saatlik çalışma süresi ve çalışma alanıyla sınırlı kalıcılık onu gerçek bir sunucu değil, hacker oyuncağı yapar.

Öğrenmek, test etmek, kurtarılabilir bir ortamda hızlıca bir Linux sisteminde hata ayıklamak için? Mükemmel. Ciddi bir şey barındırmak için? Gerçek bir VPS al.

Ama istenirse geri yüklenebilen geçici bir Linux ortamı için? Bu düpedüz harika.


Tüm bunların arkasındaki desen

Geri çekilip bakarsan repo-to-vps ve e-posta botu (diğer makalem) aynı fikre dayanır:

Git sadece bir sürüm yöneticisi değildir. Ücretsiz, sürümlü, bir API üzerinden erişilebilir kalıcı bir depolama sistemidir.

Stateless bir sistemin (GitHub Actions, bir Worker, sunucusuz bir fonksiyon) olur olmaz iki çalıştırma arasında durum tutmak istersin, git "disk" görevi görebilir.

  • E-posta botu bir git taginde lastId saklar.
  • repo-to-vps bir git dalında koca bir dosya sistemi saklar.

Aynı desen, iki ölçek. Bir tarafta bir değer, diğer tarafta bir disk.

Ve git commit --amend + force-push ortak tekniktir: mevcut durumu temsil eden, her güncellemede ezilen tek bir commit tutarsın.

Bunun için yapılmadı. Ama çalışıyor. Ve bedava.


Akılda tutulacak 3 nokta:

  1. Bir git dalı = kalıcı bir sabit disk: Dosya sistemini adanmış bir dalda sakla, başlangıçta geri yükle, atılabilir makinelere hayatta kalan bir duruma sahip ol.

  2. inotify + git = gerçek zamanlı otomatik kayıt: inotifywait değişiklikleri çekirdek seviyesinde izler ve gite anında pushlar. Temiz tek commit tutmak için git commit --amend ile.

  3. tmate koşucuyu VPS e dönüştürür: GitHub Actions makinesinde canlı SSH, otomatik yeniden başlatma ve GitHub API üzerinden tek komutla yeniden bağlanma.

Sabit disk olarak git, ikinci bölüm. Sanırım her şeyi git dallarında saklayarak bitireceğim.

Repo to VPS: trasformare GitHub Actions in VPS gratis con storage persistente

Come trasformare un runner GitHub Actions in VPS permanente con git come storage persistente: tmate, inotify e commit --amend.

GitHub ti dà un VPS gratis per 6 ore. Ho trovato come renderlo permanente.

GitHub Actions ti dà macchine Linux gratis.

Tipo, veri server Ubuntu. 2 core, 7 GB di RAM, 14 GB di disco. Gratis. Per 6 ore per run.

L'unico "problema": alla fine del run, tutto è cancellato. La macchina è usa e getta. Installi pacchetti, programmi, configuri... e puff, alla fine tutto sparisce. Come se non avessi fatto niente.

A meno che.

A meno che usi git come disco rigido.

E lì, d'un colpo, hai un VPS gratis con un disco persistente che sopravvive ai run. Ti riconnetti, tutto è ancora lì. Riprendi dove ti eri fermato.

È completamente rotto. Lascia che ti spieghi.


Il contesto: i runner GitHub Actions

Quando lanci un workflow GitHub Actions, GitHub ti dà una VM.

È fatta per buildare il tuo codice, lanciare i tuoi test, fare deploy. Il workflow gira, fa il suo lavoro, e la macchina è distrutta.

Ma niente ti impedisce di fare altro con questa VM. Tipo, aprire una shell SSH sopra e usarla come un server.

Il vincolo, è che queste macchine sono stateless e temporanee:

  • Temporanea: 6 ore max per run (timeout-minutes: 360, il tetto di GitHub)
  • Stateless: tutto è cancellato alla fine

Quindi per farne un VPS usabile, bisogna risolvere due problemi:

  1. Come connettersi sopra in tempo reale?
  2. Come tenere il disco tra due run?

Lì diventa uno sporco hack.


Problema 1: l'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 lancia quindi 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

# recupera i link di connessione
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 sono scritti diretto 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 è davvero folle.


Problema 2: git come disco rigido

Ecco l'idea che cambia tutto.

La macchina è cancellata a ogni run. Quindi salviamo il filesystem in un branch git dedicato, chiamato filesystem.

All'avvio, lo script ripristina lo stato da questo branch:

filesystem_branch="filesystem"

# recupera il branch filesystem dal remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# ripristina il workspace da questo 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 install, le tue config: tutto è dentro.

Vedi l'idea? La macchina è usa e getta, ma il disco vive in git. Rilanci il workflow, il disco è ripristinato, riprendi esattamente dove eri.

È come un VPS che iberna. Solo che l'ibernazione è un repo git

Primo lancio: crea 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 alcuno storico: come se ripartissi da un repo vuoto.

Perché orfano? Perché NON vuoi che il tuo disco persistente si trascini tutto lo storico del tuo codice sorgente. Il disco resta isolato, con la sua vita. Inizia vergine.

E il git ls-remote --exit-code all'inizio, è solo un check pulito: "il branch esiste già sul remote?". Se sì, non tocchiamo niente. Se no, lo creiamo. Idempotente, come piace.

Il git clean selettivo: proteggi le cache

Questa riga merita di fermarci sopra:

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

git clean -fdx toglie TUTTO ciò che non è tracciato da git. Normalmente è violento: pulisce il workspace a fondo.

Ma i -e (exclude) proteggono certi file:

  • .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 quei file, romperesti la sessione attiva o perderesti la tua cache. Quindi li risparmiamo durante il reset.

Un dettaglio scemo a prima vista, ma senza questo tutto si rompe.


L'autosave: inotify che sorveglia tutto

Bene, ma come finiscono i file nel branch filesystem?

Risposta: un watcher che sorveglia TUTTI i cambiamenti di file e fa commit/push automaticamente.

Lo strumento magico è inotifywait (del pacchetto inotify-tools). Sorveglia il filesystem a livello kernel e scatta 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   # debounce se tanti cambiamenti in un colpo
  done
}

autosave &

Scomponiamo i flag inotify, perché ognuno conta:

  • -r → ricorsivo, sorveglia tutte le sottocartelle
  • -e modify,create,delete,move → reagisce a questi 4 tipi di eventi
  • --exclude '...' → una regex per ignorare certi file

Il --exclude è cruciale. Guarda cosa ignora:

  • .git → ovvio, altrimenti ogni commit innescherebbe un autosave che innescherebbe un commit... ciclo infinito. Catastrofe.
  • .apt-cache e .cache → le cache, che cambiano sempre e che non vogliamo spammare in git
  • host.conf e tmate.sock → i file di sessione, che si muovono senza sosta
  • .gitignore, .txt.swp → i file temporanei (i .swp sono i file di editing di vim)

Senza questo exclude, ti ritroveresti con un autosave che scatta in ciclo sui suoi cambiamenti. Il .git nella lista, è LA riga che ti impedisce di spararti in un piede.

Modifichi un file? inotify lo rileva istantaneamente, fa commit, fa push. In meno di un secondo, il tuo cambiamento è nel branch filesystem.

Installi un pacchetto, scrivi codice, tocchi una config: tutto è salvato in tempo reale, automaticamente, senza che tu faccia niente.

Hai letteralmente un sistema di salvataggio automatico dell'intero disco. Rotto.

Il debounce: non spammare git

Il sleep 1 dopo ogni save è un debounce.

Quando salvi un file in un editor, spesso genera più eventi filesystem in raffica (creazione di un file temp, rename, cancellazione del vecchio...). Senza debounce, innescheresti 3-4 commit per un solo salvataggio.

Lo sleep 1 dice: "aspetta un secondo dopo un save, il tempo che la raffica si calmi, prima di riascoltare". Raggruppa i cambiamenti ravvicinati in un solo commit. Furbo.

E un salvataggio periodico in più

Nel caso inotify perda un evento, c'è anche un save ogni 5 secondi:

periodic_save() {
  while true; do
    sync_from_remote   # recupera eventuali cambiamenti remoti
    sleep 5
    commit_and_push
  done
}

periodic_save &

Cintura E bretelle. Non vogliamo proprio perdere lo stato del disco.


Il dettaglio furbo: un solo commit

Se fai commit a ogni cambiamento di file, accumulerai migliaia di commit. In un'ora di sessione, il tuo storico git esplode. Il repo diventa enorme. È schifoso.

La soluzione è elegante: facciamo amend del commit esistente invece di crearne uno nuovo.

commit_and_push() {
  (
    flock -n 200 || return   # lock perché due save non girino insieme

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # non toccare gli script

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: schiaccia il commit precedente
      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 vuol dire: "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-push ancora e ancora.

Il flock è un lock: dato che ci sono due cicli di save (inotify + periodico), bisogna evitare che lancino git insieme e si pestino i piedi. Un solo processo git alla volta.

Pulito.


Il sync_from_remote: gestisci più sessioni

Tieni, un caso a cui non pensi all'inizio: e se lanci DUE run insieme? O se una sessione modifica il branch filesystem mentre un'altra gira?

Lo script gestisce questo 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: vuol dire "fai merge SOLO se possiamo avanzare pulitamente, senza creare un commit di merge".

Se entrambi i branch sono divergenti (tipo, due sessioni hanno modificato file diversi), il fast-forward fallisce silenziosamente (2>/dev/null || true) e teniamo lo stato locale. Non è un sistema di merge perfetto, ma evita corruzioni nel caso semplice dove gira una sola sessione.

Onestamente, non bisogna lanciare 3 sessioni in parallelo sullo stesso repo. Ma il codice prova comunque a non esplodere se succede. È difesa.


La cache APT: installa in fretta

C'è un dettaglio nel workflow che non paga di aspetto ma è ben pensato:

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

tmate e inotify-tools sono installati via un'azione che fa cache dei pacchetti APT.

Al primo run, scarica e installa. Ai run successivi, è ripristinato dalla cache GitHub Actions: più rapido, niente 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 torna. Ho pensato al ciclo di vita completo.


Gli script nascosti in /tmp

Ancora un dettaglio vizioso 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.) sono copiati in /tmp PRIMA di toccare il branch filesystem.

Perché? Perché quando fai il git reset --hard verso il branch filesystem (che è vuoto all'inizio, o che contiene il tuo disco), i file .github/scripts del repo sorgente spariscono dal workspace.

Ma lo script ne ha ancora bisogno durante la sessione (per aggiornare il README a ogni rilancio di tmate). Quindi li nasconde in /tmp, fuori portata di git:

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

Se non ci pensi, peni 30 minuti a capire perché il tuo script è sparito. Io ci ho pensato.


La shell su misura

Piccolo comfort: 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 un override di exit:

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

# Ctrl+D fa uguale a exit
bind -x '"\C-d": "exit"'

Quando digiti exit (o Ctrl+D), uccide pulitamente i processi tmate prima di chiudere. Evita di lasciare sessioni tmate zombie.

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 rilancia da solo

Piccolo comfort: se digiti exit nella tua shell, normalmente la sessione tmate muore e sei disconnesso per sempre.

Solo che qui, tmate è in un ciclo 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 riparte da sola. Ti riconnetti con lo stesso link.

È scemo, ma rende la sessione usabile.


La riconnessione in un comando

Come ti riconnetti dopo una disconnessione, senza andare a scavare nei log del run ogni volta?

L'indirizzo SSH di tmate è scritto in un file host.conf, committato nel branch filesystem:

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

E dato che questo file è in git, puoi recuperarlo via 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 cercare l'indirizzo SSH attuale nel repo, e ti connette. Anche se l'indirizzo è cambiato tra due sessioni.


Il flusso completo

Ricapitoliamo:

  1. Attivi il workflow (push o pulsante manuale)
  2. GitHub ti dà una VM Ubuntu
  3. Lo script ripristina il disco dal branch "filesystem"
  4. inotify inizia a sorvegliare tutti i cambiamenti
  5. periodic_save fa commit ogni 5s in backup
  6. tmate parte → genera i link SSH/web
  7. I link sono scritti nel README + host.conf
  8. Ti connetti con ssh o il terminale web
  9. Fai ciò che vuoi: ogni cambiamento di file = autosave
  10. 6 ore dopo, GitHub uccide la VM
  11. Il tuo disco è intatto nel branch "filesystem"
  12. Rilanci il workflow → torna al passo 3, tutto è ancora lì

Un VPS gratis con disco persistente. Solo con git e GitHub Actions.


Bene, bisogna essere onesti: i limiti

È un hack, non un vero VPS. Quindi:

  • 6 ore max per run. Bisogna rilanciare il workflow regolarmente. Uptime limitato a ogni run.
  • Riservato a dev ed esplorazione. Non ci ospiterai il tuo sito. È per esplorare, fare dev, debug, testare un ambiente Linux usa e getta ma recuperabile.
  • GitHub vede tutto. Sono le loro macchine. Non metterci niente di sensibile.
  • Tieni il repo privato. Esponi una shell SSH. Un repo pubblico = chiunque può potenzialmente connettervisi. Cattiva idea.
  • È al limite delle condizioni d'uso. 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 grossi

Git è fatto per testo, non per un filesystem.

Il disco persistente vive in un branch git. Quindi tutto ciò che salvi passa da git. E git:

  • gestisce male i grossi file binari (un'immagine Docker da 2 GB in git? scorda)
  • ha un limite di 100 MB per file su GitHub (hard limit, non fa push oltre)
  • raccomanda di restare sotto circa 5 GB per repo

Quindi se fai npm install di un progetto con 500 MB di node_modules, o se buildi un progetto che genera binari pesanti, il push verso filesystem o ramerà a morte, o fallirà proprio.

Il git commit --amend aiuta (un solo commit, niente storico che gonfia), ma non cambia il fatto che un file da 200 MB non passerà mai.

In pratica: funziona super per codice, config, piccoli file. Non funziona per salvare grossi dati o artefatti binari. Bisogna tenerlo a mente su ciò che fai nella sessione.

Non è uno snapshot di sistema completo

Altra sfumatura importante: il branch filesystem salva il workspace (la cartella del repo), non tutto il sistema.

Se fai apt install htop, il binario va in /usr/bin/htop, che è FUORI dal workspace. Quindi NON sarà salvato. Al prossimo run, bisogna reinstallarlo.

Ecco perché abbiamo la cache APT e il prestart.sh: per ripreparare l'ambiente di sistema a ogni avvio, visto che solo il workspace persiste.

Se vuoi che le tue install sopravvivano, bisogna metterle nel workspace (tipo, installare in una cartella locale invece che in sistema). È una ginnastica da integrare.


VPS gratis contro vero VPS: il match

repo-to-vps Vero VPS (5€/mese)
Prezzo 0€ circa 5-10€/mese
Uptime 6 ore, da rilanciare 24/7
Disco branch git, piccoli file vero SSD, vari GB
RAM circa 7 GB (generoso!) 1-2 GB spesso
CPU 2-4 core corretti 1-2 vCPU
Setup clona un template config manuale
Persistenza solo workspace sistema completo
Legittimità limite delle CGU 100% pulito

Il punto interessante è che lato spec grezze (RAM, CPU), il runner GitHub è spesso MIGLIORE di un VPS a 5€. Ma l'uptime di 6 ore e la persistenza limitata al workspace, è ciò che ne fa un giocattolo da hacker, non un vero server.

Per imparare, testare, debuggare un sistema Linux in fretta in un ambiente recuperabile? Perfetto. Per ospitare qualsiasi cosa seria? Prendi un vero VPS.

Ma per un ambiente Linux temporaneo che puoi ripristinare a volontà? È semplicemente geniale.


Il pattern dietro tutto questo

Se prendi distanza, repo-to-vps e il bot email (l'altro mio articolo) poggiano sulla stessa idea:

Git non è solo un gestore di versioni. È un sistema di storage persistente, gratis, versionato, accessibile via API.

Appena hai un sistema stateless (GitHub Actions, un Worker, una funzione serverless) e vuoi tenere uno stato tra due esecuzioni, git può servire 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 un lato, un disco dall'altro.

E il git commit --amend + force-push è la tecnica comune: tieni un solo commit che rappresenta lo stato attuale, schiacciato a ogni update.

Non è fatto per questo. Ma funziona. Ed è gratis.


I 3 punti da trattenere:

  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 tempo reale: inotifywait sorveglia i cambiamenti a livello kernel e fa push verso git istantaneamente. Con git commit --amend per tenere un solo commit pulito.

  3. tmate trasforma un runner in VPS: SSH live su una macchina GitHub Actions, con restart automatico e riconnessione in un comando via l'API GitHub.

Git come disco rigido, secondo episodio. Credo che finirò per salvare tutto in branch git

Repo to VPS: GitHub Actions in gratis VPS verwandeln mit persistentem Speicher

Wie man einen GitHub-Actions-Runner mit Git als persistentem Speicher in einen permanenten VPS verwandelt, tmate, inotify und commit --amend.

GitHub gibt dir 6h lang einen gratis VPS. Ich habe gefunden, wie man ihn permanent macht.

GitHub Actions gibt dir gratis Linux-Maschinen.

So richtige Ubuntu-Server. 2 Kerne, 7 GB RAM, 14 GB Platte. Gratis. Für 6h pro Run.

Das einzige "Problem": Am Ende des Runs wird alles gelöscht. Die Maschine ist wegwerfbar. Du installierst Pakete, du codest, du konfigurierst... und puff, am Ende verschwindet alles. Als hättest du nichts gemacht.

Außer wenn.

Außer wenn du Git als Festplatte nutzt.

Und da hast du plötzlich einen gratis VPS mit einer 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 es mich dir erklären.


Der Kontext: die GitHub-Actions-Runner

Wenn du einen GitHub-Actions-Workflow startest, gibt dir GitHub eine VM.

Das ist gemacht, um deinen Code zu bauen, deine Tests laufen zu lassen, zu deployen. Der Workflow läuft, macht seinen Job, und die Maschine wird zerstört.

Aber nichts hindert dich, etwas anderes mit dieser VM zu machen. Zum Beispiel eine SSH-Shell darauf zu öffnen und sie als Server zu nutzen.

Die Bedingung ist, dass diese Maschinen stateless und temporär sind:

  • Temporär: Max 6h pro Run (timeout-minutes: 360, GitHubs Deckel)
  • Stateless: Alles wird am Ende gelöscht

Um daraus also einen nutzbaren VPS zu machen, muss man zwei Probleme lösen:

  1. Wie verbindet man sich live darauf ?
  2. Wie behält man die Platte zwischen zwei Runs ?

Da wird es ein dreckiger Hack.


Problem 1: das Live-SSH mit tmate

tmate ist ein tmux-Fork, der eine teilbare SSH-Session erstellt.

Du startest es auf einer Maschine, es generiert zwei Links:

Du verbindest dich mit einem dieser Links, und boom, du bist in einer Shell auf der Maschine. Live.

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

# holt die Verbindungslinks
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 einem Python-Skript direkt ins README des Repos geschrieben. Du öffnest dein Repo, siehst den Verbindungslink, klickst. Schon bist du in deinem VPS.

Erstes Problem gelöst. Aber das zweite ist wirklich verrückt.


Problem 2: Git als Festplatte

Hier die Idee, die alles ändert.

Die Maschine wird bei jedem Run gelöscht. Also speichern wir das Dateisystem in einem dedizierten Git-Branch namens filesystem.

Beim Start stellt das Skript den Zustand aus diesem Branch wieder her:

filesystem_branch="filesystem"

# holt den filesystem-Branch vom Remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# stellt den Workspace aus diesem Branch wieder her
git checkout -B filesystem-workspace "refs/remotes/origin/$filesystem_branch"
git reset --hard "refs/remotes/origin/$filesystem_branch"

Der filesystem-Branch IST deine Festplatte. Deine Dateien, deine Installs, deine Configs, alles steckt drin.

Du siehst die Idee? Die Maschine ist wegwerfbar, aber die Platte lebt in Git. Du startest den Workflow neu, die Platte wird restauriert, du machst genau weiter, wo du warst.

Es ist wie ein VPS, der hiberniert. Nur dass die Hibernation ein Git-Repo ist.

Erster Start: die leere Platte erstellen

Beim allerersten Run existiert der filesystem-Branch noch nicht. Man muss ihn erstellen. 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 verwaister Branch ist ein Branch ohne jegliche Historie, als startest du von einem leeren Repo neu.

Warum verwaist? Weil du NICHT willst, dass deine persistente Platte die ganze Historie deines Sourcecodes mitschleppt. Die Platte bleibt isoliert, mit eigenem Leben. Sie startet jungfräulich.

Und das git ls-remote --exit-code am Anfang ist nur ein sauberer Check: "existiert der Branch schon auf dem Remote ?". Wenn ja, fassen wir nichts an. Wenn nein, erstellen wir ihn. Idempotent, wie wir es mögen.

Das selektive Git Clean: Caches schützen

Diese Zeile verdient, dass man stehen bleibt:

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

git clean -fdx wirft ALLES weg, was nicht von Git getrackt wird. Normalerweise ist das brutal, es räumt den Workspace gründlich auf.

Aber die -e (Exclude) schützen manche Dateien:

  • .apt-cache → der APT-Paketcache (dazu kommen wir, das ist clever)
  • .cache → generischer Cache
  • host.conf → die SSH-Adresse der Session
  • tmate.sock → der Socket der laufenden tmate-Session

Wenn du diese Dateien aufräumen würdest, brichst du die aktive Session oder verlierst deinen Cache. Also sparen wir sie beim Reset aus.

Ein auf den ersten Blick dummes Detail, aber ohne das bricht alles.


Der Autosave: inotify, das alles überwacht

Gut, aber wie kommen die Dateien in den filesystem-Branch?

Antwort: Ein Watcher, der ALLE Dateiänderungen überwacht und automatisch committet/pusht.

Das magische Tool ist inotifywait (aus dem Paket inotify-tools). Es überwacht das Dateisystem auf Kernelebene und triggert, sobald sich eine 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   # Debounce wenn viele Änderungen auf einmal
  done
}

autosave &

Zerlegen wir die inotify-Flags, denn jedes zählt:

  • -r → rekursiv, überwacht alle Unterordner
  • -e modify,create,delete,move → reagiert auf diese 4 Eventtypen
  • --exclude '...' → eine Regex, um manche Dateien zu ignorieren

Das --exclude ist entscheidend. Schau, was es ignoriert:

  • .git → natürlich, sonst würde jeder Commit einen Autosave triggern, der einen Commit triggert... Endlosschleife. Katastrophe.
  • .apt-cache und .cache → die Caches, die sich ständig ändern und die wir nicht in Git spammen wollen
  • host.conf und tmate.sock → die Sessiondateien, die sich unaufhörlich bewegen
  • .gitignore, .txt.swp → temporäre Dateien (die .swp sind vims Editierdateien)

Ohne dieses Exclude hättest du einen Autosave, der sich in Schleife auf seine eigenen Änderungen triggert. Das .git in der Liste ist DIE Zeile, die dich davor bewahrt, dir ins eigene Bein zu schießen.

Du änderst eine Datei? inotify erkennt es augenblicklich, es committet, es pusht. In weniger als einer Sekunde ist deine Änderung im filesystem-Branch.

Du installierst ein Paket, du schreibst Code, du fasst eine Config an, alles wird live gespeichert, automatisch, ohne dass du irgendwas tust.

Du hast buchstäblich ein automatisches Sicherungssystem der ganzen Platte. Kaputt.

Der Debounce: Git nicht spammen

Das sleep 1 nach jedem Save ist ein Debounce.

Wenn du eine Datei in einem Editor speicherst, generiert das oft mehrere Filesystem-Events in Salve (Erstellung einer Tempdatei, Rename, Löschen der alten...). Ohne Debounce würdest du 3-4 Commits für ein einziges Speichern triggern.

Das sleep 1 sagt: "Warte eine Sekunde nach einem Save, bis die Salve sich beruhigt, bevor du wieder lauschst". Das gruppiert nahe Änderungen in einen einzigen Commit. Clever.

Und ein periodisches Backup obendrauf

Falls inotify ein Event verpassen sollte, gibt es auch alle 5 Sekunden einen Save:

periodic_save() {
  while true; do
    sync_from_remote   # holt eventuelle Remote-Änderungen
    sleep 5
    commit_and_push
  done
}

periodic_save &

Gürtel UND Hosenträger. Wir wollen vor allem den Plattenzustand nicht verlieren.


Das clevere Detail: ein einziger Commit

Wenn du bei jeder Dateiänderung committest, häufst du Tausende Commits an. In einer Stunde Session explodiert deine Historie. Das Repo wird riesig. Das ist hässlich.

Die Lösung ist elegant: Wir amenden den existierenden Commit, statt einen neuen zu erstellen.

commit_and_push() {
  (
    flock -n 200 || return   # Lock damit nicht zwei Saves zugleich laufen

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # Scripts nicht anfassen

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : überschreibt den vorherigen 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 heißt: "Ersetze den letzten Commit durch diesen".

Dadurch hat der filesystem-Branch IMMER einen einzigen Commit. Egal wie oft du sicherst. Es ist nur ein Snapshot des aktuellen Zustands, immer wieder force-gepusht.

Das flock ist ein Lock: Da es zwei Save-Schleifen gibt (inotify + periodisch), muss man vermeiden, dass sie Git gleichzeitig starten und sich in die Quere kommen. Ein einziger Git-Prozess auf einmal.

Sauber.


Das sync_from_remote: mehrere Sessions handhaben

Tja, ein Fall, an den du anfangs nicht denkst: Was, wenn du ZWEI Runs gleichzeitig startest? Oder wenn eine Session den filesystem-Branch ändert, während eine andere läuft?

Das Skript handhabt das mit einem 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 (nur Fast-Forward) ist wichtig: Es heißt "merge NUR, wenn man sauber vorrücken kann, ohne einen Merge-Commit zu erstellen".

Wenn beide Branches divergiert sind (z. B. zwei Sessions haben verschiedene Dateien geändert), schlägt der Fast-Forward still fehl (2>/dev/null || true) und wir behalten den lokalen Zustand. Kein perfektes Mergesystem, vermeidet aber Korruptionen im simplen Fall, wo nur eine Session läuft.

Ehrlich gesagt sollte man nicht 3 Sessions parallel auf demselben Repo starten. Aber der Code versucht trotzdem, nicht zu explodieren, wenn es passiert. Defensive.


Der APT-Cache: schnell installieren

Es gibt ein Detail im Workflow, das unscheinbar zahlt, 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 via eine Action installiert, die APT-Pakete cached.

Beim ersten Run lädt und installiert das. Bei folgenden Runs wird es aus dem GitHub-Actions-Cache restauriert, schneller, kein Re-Download nötig.

Und erinnerst du dich an das git clean -fdx -e .apt-cache von vorhin? Das hängt zusammen. Der .apt-cache-Ordner ist gerade deshalb vor dem Aufräumen geschützt, damit die Pakete, die du während deiner Session installierst, minimal persistieren können.

Alles hängt zusammen. Ich habe an den kompletten Lebenszyklus gedacht.


Die in /tmp versteckten Skripte

Noch ein fieses, aber cleveres Detail. Ganz am Anfang des Skripts:

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

Die Skripte (update_readme.py usw.) werden VOR dem Anfassen des filesystem-Branches nach /tmp kopiert.

Warum? Weil wenn du den git reset --hard auf den filesystem-Branch machst (der anfangs leer ist oder deine Platte enthält), die .github/scripts-Dateien des Sourcerepos aus dem Workspace verschwinden.

Aber das Skript braucht sie noch während der Session (um das README bei jedem tmate-Neustart zu updaten). Also versteckt es sie in /tmp, außerhalb von Gits Reichweite:

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

Wenn du daran nicht denkst, rätselst du 30 Minuten, warum dein Skript verschwunden ist. Ich habe daran gedacht.


Die maßgeschneiderte Shell

Kleiner Komfort: Die Session gibt dir eine konfigurierte Shell, kein nacktes Bash.

Das prestart.sh kopiert eine 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 einen farbigen Prompt, Aliase (ll, lla, rm -i) und vor allem einen exit-Override:

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

# Ctrl+D macht dasselbe wie exit
bind -x '"\C-d": "exit"'

Wenn du exit tippst (oder Strg+D), killt das sauber die tmate-Prozesse vor dem Schließen. Das vermeidet, tmate-Zombie-Sessions zurückzulassen.

Es gibt auch eine tmate-detach-Funktion, wenn du dich trennen willst OHNE die Session zu killen (um dich später neu zu verbinden). Komfortdetail, zeigt aber das Sorgfaltsniveau.


Das tmate, das sich selbst neu startet

Kleiner Komfort: Wenn du exit in deiner Shell tippst, stirbt normalerweise die tmate-Session und du bist endgültig getrennt.

Nur dass tmate hier in einer while true-Schleife steckt:

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 machst exit? Die Session startet von allein neu. Du verbindest dich mit demselben Link neu.

Dämlich, aber macht die Session nutzbar.


Die Reconnection mit einem Command

Wie verbindest du dich nach einer Trennung neu, ohne jedes Mal in den Runlogs zu wühlen?

Die tmate-SSH-Adresse steht in einer host.conf-Datei, committet im filesystem-Branch:

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

Und da diese Datei in Git liegt, kannst du sie mit einem einzigen Command via GitHub-API holen:

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

Du startest das, es holt die aktuelle SSH-Adresse aus dem Repo und verbindet dich. Selbst wenn sich die Adresse zwischen zwei Sessions geändert hat.


Der komplette Flow

Rekapitulieren wir:

  1. Du triggerst den Workflow (Push oder manueller Button)
  2. GitHub gibt dir eine Ubuntu-VM
  3. Das Skript stellt die Platte aus dem "filesystem"-Branch wieder her
  4. inotify fängt an, alle Änderungen zu überwachen
  5. periodic_save committet alle 5s als Backup
  6. tmate startet → generiert die SSH/Web-Links
  7. Die Links werden in README + host.conf geschrieben
  8. Du verbindest dich mit ssh oder Webterminal
  9. Du machst, was du willst, jede Dateiänderung = Autosave
  10. 6h später killt GitHub die VM
  11. Deine Platte ist intakt im "filesystem"-Branch
  12. Du startest den Workflow neu → zurück zu Schritt 3, alles ist noch da

Ein gratis VPS mit persistenter Platte. Nur mit Git und GitHub Actions.


Gut, ehrlich sein: die Grenzen

Es ist ein Hack, kein echter VPS. Also:

  • Max 6h pro Run. Man muss den Workflow regelmäßig neu starten. Uptime pro Run begrenzt.
  • Reserviert für Dev und Exploration. Du wirst deine Seite nicht darauf hosten. Es ist zum Explorieren, Entwickeln, Debuggen, schnell eine wegwerfbare, aber wiederherstellbare Linux-Umgebung testen.
  • GitHub sieht alles. Es sind ihre Maschinen. Nichts Sensibles drauflegen.
  • Repo privat halten. Du exponierst eine SSH-Shell. Ein öffentliches Repo = potenziell kann sich jeder verbinden. Schlechte Idee.
  • An der Grenze der Nutzungsbedingungen. GitHub Actions ist für CI/CD gemacht, nicht für gratis VPS. Also sparsam nutzen, für Legitimes, ohne Missbrauch.

Die echte Achillesferse: Git hasst große Dateien

Git ist für Text gemacht, nicht für ein Dateisystem.

Die persistente Platte lebt in einem Git-Branch. Also geht alles, was du sicherst, durch Git. Und Git:

  • Handhabt große Binärdateien schlecht (ein 2-GB-Docker-Image in Git? Vergiss es)
  • Hat ein 100-MB-Limit pro Datei auf GitHub (hartes Limit, pusht nicht darüber)
  • Empfiehlt, unter ~5 GB pro Repo zu bleiben

Wenn du also npm install für ein Projekt mit 500 MB node_modules machst oder ein Projekt baust, das schwere Binaries generiert, wird der Push nach filesystem entweder zu Tode ruckeln oder glatt scheitern.

Das git commit --amend hilft (ein einziger Commit, keine aufblähende Historie), ändert aber nichts daran, dass eine 200-MB-Datei nie durchgeht.

Grob gesagt: Es funktioniert super für Code, Configs, kleine Dateien. Es funktioniert nicht zum Speichern großer Daten oder Binärartefakte. Das muss man im Kopf behalten bei dem, was du in deiner Session machst.

Kein komplettes Systemsnapshot

Andere wichtige Nuance: Der filesystem-Branch sichert den Workspace (den Repo-Ordner), nicht das ganze System.

Wenn du apt install htop machst, geht das Binary nach /usr/bin/htop, was AUßERHALB des Workspaces liegt. Also wird es NICHT gesichert. Beim nächsten Run musst du es neu installieren.

Deshalb haben wir den APT-Cache und das prestart.sh: Um die Systemumgebung bei jedem Start neu vorzubereiten, da nur der Workspace persistiert.

Wenn du willst, dass deine Installs überleben, musst du sie in den Workspace legen (z. B. in einen lokalen Ordner statt systemweit installieren). Eine einzuübende Gymnastik.


Gratis-VPS vs. echter VPS: das Match

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 !) Oft 1-2 GB
CPU 2-4 korrekte Kerne 1-2 vCPU
Setup Template klonen Manuelle Config
Persistenz Nur Workspace Komplettes System
Legitimität AGB-Grenze 100 % clean

Der interessante Punkt ist, dass rohe Specs-Seite (RAM, CPU) der GitHub-Runner oft BESSER ist als ein 5€-VPS. Aber die 6h-Uptime und die auf den Workspace begrenzte Persistenz machen daraus ein Hacker-Spielzeug, kein echter Server.

Zum Lernen, Testen, schnell ein Linux-System in einer wiederherstellbaren Umgebung debuggen? Perfekt. Um irgendwas Ernstes zu hosten? Nimm einen echten VPS.

Aber für eine temporäre Linux-Umgebung, die du nach Belieben restaurieren kannst? Das ist einfach genial.


Das Pattern hinter allem

Wenn du zurücktrittst, beruhen repo-to-vps und der Mailbot (mein anderer Artikel) auf derselben Idee:

Git ist nicht nur ein Versionsmanager. Es ist ein persistentes, gratis, versioniertes, via API erreichbares Speichersystem.

Sobald du ein stateless System hast (GitHub Actions, ein Worker, eine Serverless-Funktion) und Zustand zwischen zwei Ausführungen behalten willst, kann Git als "Platte" dienen.

  • Der Mailbot speichert eine lastId in einem Git-Tag.
  • repo-to-vps speichert ein ganzes Dateisystem in einem Git-Branch.

Gleiches Pattern, zwei Maßstäbe. Ein Wert auf der einen, eine Platte auf der anderen Seite.

Und git commit --amend + Force-Push ist die gemeinsame Technik: Du behältst einen einzigen Commit, der den aktuellen Zustand darstellt, bei jedem Update überschrieben.

Dafür ist es nicht gemacht. Aber es funktioniert. Und es ist gratis.


Die 3 Punkte zum Mitnehmen :

  1. Ein Git-Branch = eine persistente Festplatte, speichere dein Dateisystem in einem dedizierten Branch, stelle beim Start wieder her, und du hast einen Zustand, der wegwerfbare Maschinen überlebt.

  2. inotify + Git = Live-Autosave, inotifywait überwacht Änderungen auf Kernelebene und pusht augenblicklich nach Git. Mit git commit --amend, um einen einzigen sauberen Commit zu behalten.

  3. tmate verwandelt einen Runner in einen VPS, Live-SSH auf eine GitHub-Actions-Maschine, mit automatischem Neustart und Ein-Command-Reconnection via GitHub-API.

Git als Festplatte, zweite Episode. Ich glaube, ich werde am Ende alles in Git-Branches speichern.

Repo to VPS : превратить GitHub Actions в бесплатный VPS с персистентным хранилищем

Как превратить раннер GitHub Actions в постоянный VPS с git как персистентным хранилищем -- tmate, inotify и commit --amend.

GitHub даёт бесплатный VPS на 6ч. Я нашёл, как сделать его постоянным.

GitHub Actions даёт бесплатные машины Linux.

Типа настоящие серверы Ubuntu. 2 ядра, 7 ГБ RAM, 14 ГБ диска. Бесплатно. На 6ч за запуск.

Единственная "проблема" : в конце запуска всё стирается. Машина одноразовая. Ставишь пакеты, кодишь, настраиваешь... и пуф, в конце всё исчезает. Будто ничего не делал.

Если бы не.

Если бы не использовать git как жёсткий диск.

И тут вдруг у тебя бесплатный VPS с персистентным диском, переживающим запуски. Переподключаешься, всё ещё на месте. Продолжаешь, где остановился.

Это совершенно сломано. Дай объясню.


Контекст : раннеры GitHub Actions

Когда запускаешь воркфлоу GitHub Actions, GitHub выдаёт тебе VM.

Это сделано, чтобы билдить код, гонять тесты, деплоить. Воркфлоу крутится, делает работу, а машина уничтожается.

Но ничто не мешает делать с этой VM что-то ещё. Типа открыть на ней шелл SSH и использовать как сервер.

Ограничение в том, что эти машины stateless и временные :

  • Временная : 6ч макс за запуск (timeout-minutes: 360, потолок GitHub)
  • Stateless : в конце всё стирается

Так что чтобы сделать из этого usable VPS, надо решить две проблемы :

  1. Как подключиться туда в реальном времени?
  2. Как сохранить диск между двумя запусками?

Здесь становится грязным хаком.


Проблема 1 : живой SSH с tmate

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}')

А эти ссылки пишутся прямо в README репозитория скриптом Python. Открываешь свой репозиторий, видишь ссылку подключения, кликаешь. Вот ты в своём VPS.

Первая проблема решена. Но вторая по-настоящему дикая.


Проблема 2 : git как жёсткий диск

Вот идея, меняющая всё.

Машина стирается при каждом запуске. Так что храним файловую систему в выделенной ветке git под названием filesystem.

На старте скрипт восстанавливает состояние из этой ветки :

filesystem_branch="filesystem"

# забираем ветку filesystem с remote
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, впадающий в гибернацию. Только гибернация это git-репозиторий.

Первый запуск : создать пустой диск

При самом первом запуске ветки 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?

Ответ : вотчер, наблюдающий ВСЕ изменения файлов и автоматически коммитящий/пушащий.

Волшебный инструмент это 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, если много изменений разом
  done
}

autosave &

Разберём флаги inotify, потому что каждый считается :

  • -r → рекурсивно, наблюдает все подпапки
  • -e modify,create,delete,move → реагирует на эти 4 типа событий
  • --exclude '...' → regex для игнора отдельных файлов

--exclude критичен. Смотри, что он игнорирует :

  • .git → очевидно, иначе каждый коммит триггерил бы автосейв, триггерящий коммит... бесконечный цикл. Катастрофа.
  • .apt-cache и .cache → кэши, меняющиеся всё время, которые не хочется спамить в git
  • host.conf и tmate.sock → файлы сессии, шевелящиеся без остановки
  • .gitignore, .txt.swp → временные файлы (.swp это файлы редактирования vim)

Без этого exclude получил бы автосейв, срабатывающий по кругу на собственных изменениях. .git в списке это ТА строка, не дающая выстрелить себе в ногу.

Меняешь файл? inotify детектит мгновенно, это коммитится, пушится. Меньше чем за секунду твоё изменение в ветке filesystem.

Ставишь пакет, пишешь код, трогаешь конфиг -- всё сохраняется в реальном времени, автоматически, без твоих действий.

Буквально система автосохранения целого диска. Сломано.

Debounce : не спамить git

sleep 1 после каждого сейва это debounce.

Когда сохраняешь файл в редакторе, часто это генерирует несколько событий файловой системы очередью (создание temp-файла, rename, удаление старого...). Без debounce триггерил бы 3-4 коммита на одно сохранение.

sleep 1 говорит : "подожди секунду после сейва, пока очередь успокоится, прежде чем слушать снова". Это группирует близкие изменения в один коммит. Умно.

А ещё периодический сейв сверху

На случай если inotify пропустит событие, есть ещё сейв каждые 5 секунд :

periodic_save() {
  while true; do
    sync_from_remote   # забирает возможные удалённые изменения
    sleep 5
    commit_and_push
  done
}

periodic_save &

И ремень, И подтяжки. Главное не потерять состояние диска.


Умная деталь : один коммит

Если коммитить при каждом изменении файла, накопишь тысячи коммитов. За час сессии история 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    # AMEND : затирает предыдущий коммит
      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 ВСЕГДА на одном коммите. Сколько бы раз ни сохранялся. Это просто снапшот текущего состояния, форспушенный снова и снова.

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 (только fast-forward) важен : значит "мерджи ТОЛЬКО если можно чисто продвинуться вперёд, без создания merge-коммита".

Если обе ветки разошлись (типа две сессии меняли разные файлы), fast-forward молча падает (2>/dev/null || true), и сохраняем локальное состояние. Это не идеальная merge-система, но избегает порчи в простом случае, когда крутится одна сессия.

Честно, не надо запускать 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" ...

Если об этом не подумать, полчаса недоумеваешь, почему скрипт исчез. Я подумал.


Кастомный шелл

Маленькое удобство : сессия даёт настроенный шелл, а не голый 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) и главное 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), это чисто убивает процессы 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 коммитит каждые 5s как бэкап
  6. tmate стартует → генерирует SSH/веб-ссылки
  7. Ссылки пишутся в README + host.conf
  8. Подключаешься по ssh или веб-терминалу
  9. Делаешь что хочешь -- каждое изменение файла = автосейв
  10. Через 6ч GitHub убивает VM
  11. Твой диск цел в ветке "filesystem"
  12. Перезапускаешь воркфлоу → назад к шагу 3, всё ещё на месте

Бесплатный VPS с персистентным диском. Только с git и GitHub Actions.


Ладно, честно : лимиты

Это хак, а не настоящий VPS. Так что :

  • 6ч макс за запуск. Надо регулярно перезапускать воркфлоу. Аптайм ограничен каждым запуском.
  • Только для dev и исследования. Не будешь хостить на этом свой сайт. Это для исследования, dev, отладки, теста одноразового, но восстанавливаемого окружения 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 помогает (один коммит, без раздувающейся истории), но не меняет того, что файл на 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€/мес
Аптайм 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 хранит целый filesystem в ветке git.

Тот же паттерн, два масштаба. Одно значение с одной стороны, диск с другой.

А git commit --amend + force-push это общая техника : держишь один коммит, представляющий текущее состояние, затираемый при каждом апдейте.

Это не для того сделано. Но работает. И бесплатно.


3 пункта запомнить :

  1. Ветка git = персистентный жёсткий диск -- Храни свой filesystem в выделенной ветке, восстанавливай на старте, и получаешь состояние, переживающее одноразовые машины.

  2. inotify + git = автосейв реального времени -- inotifywait наблюдает изменения на уровне kernel и мгновенно пушит в git. С git commit --amend, чтобы держать один чистый коммит.

  3. tmate превращает раннер в VPS -- Живой SSH на машину GitHub Actions, с авторестартом и переподключением одной командой через API GitHub.

Git как жёсткий диск, второй эпизод. Думаю, я в итоге всё буду хранить в ветках git

Repo to VPS: transformar GitHub Actions en VPS gratis con almacenamiento persistente

Cómo transformar un runner GitHub Actions en VPS permanente con git como almacenamiento persistente: tmate, inotify y commit --amend.

GitHub te da un VPS gratis durante 6h. Encontré cómo hacerlo permanente.

GitHub Actions te da máquinas Linux gratis.

O sea, verdaderos servidores Ubuntu. 2 núcleos, 7 GB de RAM, 14 GB de disco. Gratis. Durante 6h por run.

El único "problema": al final del run, todo se borra. La máquina es desechable. Instalas paquetes, programas, configuras... y puff, al final todo desaparece. Como si no hubieras hecho nada.

Salvo si.

Salvo si usas git como disco duro.

Y ahí, de golpe, tienes un VPS gratis con un disco persistente que sobrevive a los runs. Te reconectas, todo sigue ahí. Retomas donde te habías quedado.

Está completamente roto. Déjame explicarte.


El contexto: los runners GitHub Actions

Cuando lanzas un workflow GitHub Actions, GitHub te da una VM.

Está hecha para compilar tu código, lanzar tus tests, desplegar. El workflow corre, hace su trabajo, y la máquina se destruye.

Pero nada te impide hacer otra cosa con esa VM. Tipo, abrir un shell SSH encima y usarla como un servidor.

La restricción es que esas máquinas son stateless y temporales:

  • Temporal: 6h máx por run (timeout-minutes: 360, el tope de GitHub)
  • Stateless: todo se borra al final

Así que para convertirla en un VPS usable, hay que resolver dos problemas:

  1. ¿Cómo conectarse en tiempo real?
  2. ¿Cómo guardar el disco entre dos runs?

Ahí se vuelve un hack guarro.


Problema 1: el SSH en vivo con tmate

tmate es un fork de tmux que crea una sesión SSH compartible.

Lo lanzas en una máquina, 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 lanza por tanto 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

# recupera los enlaces de conexión
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 por un script Python. Abres tu repo, ves el enlace de conexión, haces clic. Ya estás en tu VPS.

Primer problema resuelto. Pero es el segundo el que es de verdad loco.


Problema 2: git como disco duro

Aquí la idea que lo cambia todo.

La máquina se borra en cada run. Así que guardamos el sistema de archivos en una rama git dedicada, llamada filesystem.

Al arrancar, el script restaura el estado desde esa rama:

filesystem_branch="filesystem"

# recupera la rama filesystem desde el remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# restaura el workspace desde esa rama
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 installs, tus configs: todo está dentro.

¿Ves la idea? La máquina es desechable, pero el disco vive en git. Vuelves a lanzar el workflow, el disco se restaura, retomas justo donde estabas.

Es como un VPS que hiberna. Salvo que la hibernación es un repo git.

Primer lanzamiento: crear el disco vacío

En el primer run, la rama filesystem aún 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 partieras de 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 queda aislado, con su propia vida. Empieza virgen.

Y el git ls-remote --exit-code del principio es solo un check limpio: "¿la rama ya existe en el remote?". Si sí, no tocamos nada. Si no, la creamos. Idempotente, como nos gusta.

El git clean selectivo: proteger los cachés

Esta línea merece detenerse en ella:

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

git clean -fdx quita TODO lo no trackeado por git. Normalmente es violento: limpia el workspace a fondo.

Pero los -e (exclude) protegen ciertos archivos:

  • .apt-cache → el caché de los paquetes APT (volveremos, es ingenioso)
  • .cache → caché genérico
  • host.conf → la dirección SSH de la sesión
  • tmate.sock → el socket de la sesión tmate en curso

Si limpiaras esos archivos, romperías la sesión activa o perderías tu caché. Así que los perdonamos durante el reset.

Un detalle tonto a primera vista, pero sin eso todo peta.


El autosave: inotify que lo vigila todo

Bueno, pero ¿cómo llegan los archivos a la rama filesystem?

Respuesta: un watcher que vigila TODOS los cambios de archivos y commitea/pushea automáticamente.

La herramienta mágica es inotifywait (del paquete inotify-tools). Vigila el filesystem a nivel del kernel y dispara en cuanto 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   # debounce si montón de cambios de golpe
  done
}

autosave &

Diseccionemos los flags inotify, porque cada uno cuenta:

  • -r → recursivo, vigila todos los subdirectorios
  • -e modify,create,delete,move → reacciona a esos 4 tipos de eventos
  • --exclude '...' → una regex para ignorar ciertos archivos

El --exclude es crucial. Mira lo que ignora:

  • .git → evidente, si no cada commit dispararía un autosave que dispararía un commit... bucle infinito. Catástrofe.
  • .apt-cache y .cache → los cachés, que cambian todo el tiempo y no queremos spamear en git
  • host.conf y tmate.sock → los archivos de sesión, que se mueven sin parar
  • .gitignore, .txt.swp → los archivos temporales (los .swp son los archivos de edición de vim)

Sin ese exclude, acabarías con un autosave que se dispara en bucle sobre sus propios cambios. El .git en la lista es LA línea que te impide pegarte un tiro en el pie.

¿Modificas un archivo? inotify lo detecta al instante, commitea, pushea. En menos de un segundo, tu cambio está en la rama filesystem.

¿Instalas un paquete, escribes código, tocas una config? Todo se guarda en tiempo real, automáticamente, sin que hagas nada.

Tienes literalmente un sistema de guardado automático del disco entero. Roto.

El debounce: no spamear git

El sleep 1 tras cada save es un debounce.

Cuando guardas un archivo en un editor, a menudo genera varios eventos filesystem en ráfaga (creación de un archivo temp, rename, borrado del viejo...). Sin debounce, dispararías 3-4 commits para un solo guardado.

El sleep 1 dice: "espera un segundo tras un save, que la ráfaga se calme, antes de volver a escuchar". Eso agrupa los cambios cercanos en un solo commit. Ingenioso.

Y un guardado periódico además

Por si inotify pierde un evento, también hay un save cada 5 segundos:

periodic_save() {
  while true; do
    sync_from_remote   # recupera los cambios remotos eventuales
    sleep 5
    commit_and_push
  done
}

periodic_save &

Cinturón Y tirantes. Sobre todo no queremos perder el estado del disco.


El detalle ingenioso: un solo commit

Si commiteas en cada cambio de archivo, vas a acumular 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: enmendamos el commit existente en vez de crear uno nuevo.

commit_and_push() {
  (
    flock -n 200 || return   # lock para que dos saves no corran a la vez

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # no toca los scripts

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND: machaca el commit previo
      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 SIEMPRE tiene un solo commit. Da igual cuántas veces guardes. Es solo un snapshot del estado actual, con force-push una y otra vez.

El flock es un cerrojo: como hay dos bucles de save (inotify + periódico), hay que evitar que lancen git a la vez y se pisen. Un solo proceso git a la vez.

Limpio.


El sync_from_remote: gestionar varias sesiones

Mira, un caso en el que no piensas al principio: ¿y si lanzas DOS runs a la vez? ¿O si una sesión modifica la rama filesystem mientras otra corre?

El script gestiona eso 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 "mergea SOLO si se puede avanzar limpiamente, sin crear un commit de merge".

Si ambas ramas divergieron (tipo, dos sesiones modificaron archivos distintos), el fast-forward falla silenciosamente (2>/dev/null || true) y guardamos el estado local. No es un sistema de merge perfecto, pero evita las corrupciones en el caso simple donde una sola sesión corre.

Honestamente, no hay que lanzar 3 sesiones en paralelo sobre el mismo repo. Pero el código aun así intenta no explotar si pasa. Es defensa.


El caché APT: instalar rápido

Hay un detalle en el workflow que no lo parece 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 vía una acción que cachea los paquetes APT.

En el primer run, descarga e instala. En los runs siguientes, se restaura desde el caché GitHub Actions: más rápido, sin redescargar.

¿Y te acuerdas del git clean -fdx -e .apt-cache de antes? Está ligado. La carpeta .apt-cache está protegida de la limpieza justo para que los paquetes que instales durante tu sesión puedan persistir un mínimo.

Todo encaja. Pensé en el ciclo de vida completo.


Los scripts escondidos en /tmp

Otro detalle vicioso pero ingenioso. 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 en /tmp ANTES de tocar la rama filesystem.

¿Por qué? Porque cuando haces el git reset --hard hacia la rama filesystem (vacía al principio, o con tu disco), los archivos .github/scripts del repo fuente desaparecen del workspace.

Pero el script aún los necesita durante la sesión (para actualizar el README en cada relanzamiento de tmate). 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 eso, te tiras 30 minutos sin entender por qué tu script desapareció. Yo pensé en eso.


El shell a medida

Pequeña comodidad: la sesión te da un shell configurado, no un bash desnudo.

El 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

Y ese .bashrc contiene un prompt coloreado, alias (ll, lla, rm -i), y sobre todo un override de exit:

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

# Ctrl+D hace lo mismo que exit
bind -x '"\C-d": "exit"'

Cuando escribes exit (o Ctrl+D), mata limpiamente los procesos tmate antes de cerrar. Eso evita dejar sesiones tmate zombis.

También hay una función tmate-detach si quieres desconectarte SIN matar la sesión (para reconectarte luego). Detalle de comodidad, pero muestra el nivel de cuidado.


El tmate que se relanza solo

Pequeña comodidad: si escribes exit en tu shell, normalmente la sesión tmate muere y quedas desconectado 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

¿Haces exit? La sesión se reinicia sola. Te reconectas con el mismo enlace.

Es tonto, pero hace la sesión usable.


La reconexión en un comando

¿Cómo te reconectas tras una desconexión, sin rebuscar en los logs del run cada vez?

La dirección SSH de tmate se escribe en un archivo host.conf, 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 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 eso, busca la dirección SSH actual en el repo, y te conecta. Aunque la dirección haya cambiado entre dos sesiones.


El flow completo

Recapitulemos:

  1. Disparas 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 commitea cada 5s en 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: cada cambio de archivo = autosave
  10. 6h después, GitHub mata la VM
  11. Tu disco queda 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.


Bueno, hay que ser honesto: los límites

Es un hack, no un verdadero VPS. Así que:

  • 6h máx por run. Hay que relanzar el workflow regularmente. Uptime limitado en cada run.
  • Reservado al dev y a la exploración. No vas a alojar tu sitio ahí. Es para explorar, desarrollar, depurar, probar un entorno Linux desechable pero recuperable.
  • GitHub lo ve todo. Son sus máquinas. No metas nada sensible.
  • Mantén el repo privado. Expones un shell SSH. Un repo público = cualquiera puede potencialmente conectarse. Mala idea.
  • Está al límite de las condiciones de uso. GitHub Actions está hecho para CI/CD, no para VPS gratis. Así que úsalo con parsimonia, para lo legítimo, sin abusar.

El verdadero talón de Aquiles: git odia los archivos grandes

Git está hecho para texto, no para un filesystem.

El disco persistente vive en una rama git. Así que todo lo que guardas pasa por git. Y git:

  • gestiona mal los archivos binarios grandes (¿una imagen Docker de 2 GB en git? olvida)
  • tiene un límite de 100 MB por archivo en GitHub (límite duro, no hace push más allá)
  • recomienda quedarse bajo ~5 GB por repo

Así que si haces npm install de un proyecto con 500 MB de node_modules, o si compilas un proyecto que genera binarios pesados, el push hacia filesystem o bien irá lentísimo, o bien fallará directamente.

El git commit --amend ayuda (un solo commit, sin historial que engorde), pero no cambia que un archivo de 200 MB nunca pasará.

En resumen: funciona superbién para código, configs, archivos pequeños. No funciona para guardar datos gordos o artefactos binarios. Hay que tener eso en mente sobre lo que haces en tu sesión.

No es un snapshot sistema completo

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. Así que NO se guardará. En el próximo run, hay que reinstalarlo.

Por eso tenemos el caché APT y el prestart.sh: para repreparar el entorno sistema en cada arranque, visto que solo el workspace persiste.

Si quieres que tus installs sobrevivan, hay que meterlas en el workspace (tipo, instalar en una carpeta local en vez de en sistema). Es una gimnasia que integrar.


VPS gratis vs verdadero VPS: el duelo

repo-to-vps Verdadero VPS (5€/mes)
Precio 0€ ~5-10€/mes
Uptime 6h, a relanzar 24/7
Disco rama git, archivos pequeños verdadero SSD, varios GB
RAM ~7 GB (¡generoso!) 1-2 GB a menudo
CPU 2-4 núcleos correctos 1-2 vCPU
Setup clona un template config manual
Persistencia workspace solo sistema completo
Legitimidad límite de las CGU 100% limpio

El punto interesante es que en specs brutas (RAM, CPU), el runner GitHub suele ser MEJOR que un VPS a 5€. Pero el uptime de 6h y la persistencia limitada al workspace lo convierten en un juguete de hacker, no en un verdadero servidor.

¿Para aprender, probar, depurar un sistema Linux rápido en un entorno recuperable? Perfecto. ¿Para alojar algo serio? Coge un verdadero VPS.

Pero para un entorno Linux temporal que puedes restaurar a voluntad, es simplemente genial.


El patrón tras todo eso

Si tomas distancia, repo-to-vps y el bot email (mi otro artículo) se apoyan en la misma idea:

Git no es solo un gestor de versiones. Es un sistema de almacenamiento persistente, gratis, versionado, accesible vía una API.

En cuanto tienes un sistema stateless (GitHub Actions, un Worker, una función serverless) y quieres guardar un estado entre dos ejecuciones, git puede servir de "disco".

  • El bot email guarda un lastId en un tag git.
  • repo-to-vps guarda un filesystem entero en una rama 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: guardas un solo commit que representa el estado actual, machacado en cada update.

No está hecho para eso. Pero funciona. Y es gratis.


Los 3 puntos a recordar:

  1. Una rama git = un disco duro persistente: Guarda tu filesystem en una rama dedicada, restaura al arrancar, y tienes un estado que sobrevive a las máquinas desechables.

  2. inotify + git = autosave en tiempo real: inotifywait vigila los cambios a nivel kernel y hace push hacia git al instante. Con git commit --amend para mantener un solo commit limpio.

  3. tmate transforma un runner en VPS: SSH en vivo sobre una máquina GitHub Actions, con reinicio automático y reconexión en un comando vía la API GitHub.

Git como disco duro, segundo episodio. Creo que voy a terminar guardándolo todo en ramas git.

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 memberimu VPS gratis selama 6 jam. Saya menemukan cara membuatnya permanen.

GitHub Actions memberimu mesin Linux gratis.

Maksudnya, server Ubuntu sungguhan. 2 core, 7 GB RAM, 14 GB disk. Gratis. Selama 6 jam per run.

Satu-satunya "masalah" : di akhir run, semuanya dihapus. Mesinnya sekali pakai. Kamu menginstal paket, ngoding, mengonfigurasi... dan tiba-tiba, di akhir semuanya hilang. Seolah kamu tidak melakukan apa-apa.

Kecuali.

Kecuali kamu memakai git sebagai hard disk.

Dan tiba-tiba, kamu punya VPS gratis dengan disk persisten yang bertahan antar run. Kamu menyambung ulang, semuanya masih ada. Kamu lanjut tepat di mana kamu berhenti.

Ini benar-benar rusak. Biar saya jelaskan.


Konteksnya : runner GitHub Actions

Ketika kamu menjalankan workflow GitHub Actions, GitHub memberimu VM.

Ini dibuat untuk build kodemu, menjalankan tesmu, deploy. Workflow berjalan, mengerjakan tugasnya, dan mesin dihancurkan.

Tapi tidak ada yang melarangmu melakukan hal lain dengan VM ini. Misalnya, membuka shell SSH di atasnya dan memakainya sebagai server.

Batasannya adalah mesin-mesin ini stateless dan sementara :

  • Sementara : 6 jam maks per run (timeout-minutes: 360, plafon GitHub)
  • Stateless : semuanya dihapus di akhir

Jadi untuk menjadikannya VPS yang bisa dipakai, harus menyelesaikan dua masalah :

  1. Bagaimana terhubung ke sana waktu nyata?
  2. Bagaimana menjaga disk antara dua run?

Di sinilah jadi hack kotor.


Masalah 1 : SSH live dengan tmate

tmate adalah fork tmux yang membuat sesi SSH yang bisa dibagikan.

Kamu menjalankannya di mesin, ia membangkitkan dua tautan :

Kamu terhubung dengan salah satu tautan ini, dan boom, kamu ada di shell di mesin. Waktu nyata.

Workflow jadi 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

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

Dan tautan ini ditulis langsung di README repo oleh skrip Python. Kamu membuka repomu, melihat tautan koneksi, mengklik. Kamu pun ada di VPS-mu.

Masalah pertama selesai. Tapi yang kedua benar-benar gila.


Masalah 2 : git sebagai hard disk

Inilah ide yang mengubah segalanya.

Mesin dihapus di setiap run. Jadi kita menyimpan sistem berkas di branch git khusus, bernama filesystem.

Saat startup, skrip memulihkan keadaan dari branch ini :

filesystem_branch="filesystem"

# mengambil branch filesystem dari remote
git fetch origin "$filesystem_branch":refs/remotes/origin/$filesystem_branch

# memulihkan 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-mu. Berkasmu, instalasimu, configmu -- semuanya ada di dalamnya.

Kamu paham idenya? Mesinnya sekali pakai, tapi disknya hidup di git. Kamu menjalankan ulang workflow, disk dipulihkan, kamu lanjut tepat di mana kamu berhenti.

Ini seperti VPS yang hibernasi. Kecuali hibernasinya repo git

Peluncuran pertama : membuat disk kosong

Di run pertama, 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 itulah kuncinya. Branch yatim adalah branch tanpa riwayat apa pun -- seolah kamu mulai dari repo kosong.

Kenapa yatim? Karena kamu TIDAK ingin disk persistenmu menyeret seluruh riwayat kode sumbermu. Disk tetap terisolasi, dengan hidupnya sendiri. Ia mulai perawan.

Dan git ls-remote --exit-code di awal, itu cuma cek bersih : "apakah branch sudah ada di remote?". Kalau ya, kita tidak menyentuh apa-apa. Kalau tidak, kita membuatnya. Idempoten, seperti yang kita suka.

Git clean selektif : melindungi cache

Baris ini layak berhenti sebentar :

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

git clean -fdx menghapus SEMUA yang tidak di track git. Normalnya ini keras -- membersihkan workspace habis-habisan.

Tapi -e (exclude) melindungi berkas tertentu :

  • .apt-cache → cache paket APT (nanti kita bahas, ini cerdik)
  • .cache → cache generik
  • host.conf → alamat SSH sesi
  • tmate.sock → socket sesi tmate yang berjalan

Kalau kamu membersihkan berkas-berkas itu, kamu merusak sesi aktif atau kehilangan cache-mu. Jadi kita mengampuninya selama reset.

Detail tampak bodoh pada pandangan pertama, tapi tanpanya semuanya meledak.


Autosave : inotify yang mengawasi segalanya

Oke, tapi bagaimana berkas-berkas sampai di branch filesystem?

Jawaban : watcher yang mengawasi SEMUA perubahan berkas dan commit/push otomatis.

Alat ajaibnya adalah inotifywait (dari paket inotify-tools). Ia mengawasi filesystem di tingkat kernel dan memicu begitu berkas 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 bedah flag inotify, karena masing-masing penting :

  • -r → rekursif, mengawasi semua subfolder
  • -e modify,create,delete,move → bereaksi pada 4 tipe event ini
  • --exclude '...' → regex untuk mengabaikan berkas tertentu

--exclude ini krusial. Lihat apa yang ia abaikan :

  • .git → jelas, kalau tidak setiap commit memicu autosave yang memicu commit... loop tak berujung. Bencana.
  • .apt-cache dan .cache → cache, yang berubah terus dan tidak ingin kita spam ke git
  • host.conf dan tmate.sock → berkas sesi, yang bergerak tanpa henti
  • .gitignore, .txt.swp → berkas sementara (.swp itu berkas edit vim)

Tanpa exclude ini, kamu berakhir dengan autosave yang terpicu berulang pada perubahannya sendiri. .git di daftar, itulah baris yang mencegahmu menembak kakimu sendiri.

Kamu mengubah berkas? inotify mendeteksinya seketika, commit, push. Dalam kurang dari sedetik, perubahanmu ada di branch filesystem.

Kamu menginstal paket, menulis kode, menyentuh config -- semuanya disimpan waktu nyata, otomatis, tanpa kamu melakukan apa pun.

Kamu secara harfiah punya sistem simpan otomatis seluruh disk. Rusak.

Debounce : jangan spam git

sleep 1 setelah setiap save adalah debounce.

Ketika kamu menyimpan berkas di editor, sering itu membangkitkan beberapa event filesystem beruntun (pembuatan berkas temp, rename, penghapusan yang lama...). Tanpa debounce, kamu memicu 3-4 commit untuk satu penyimpanan.

sleep 1 bilang : "tunggu sedetik setelah save, sampai beruntunnya tenang, sebelum mendengar lagi". Itu mengelompokkan perubahan yang berdekatan jadi satu commit. Cerdik.

Dan simpanan berkala sebagai tambahan

Seandainya inotify melewatkan event, ada juga save setiap 5 detik :

periodic_save() {
  while true; do
    sync_from_remote   # mengambil perubahan jarak jauh yang mungkin ada
    sleep 5
    commit_and_push
  done
}

periodic_save &

Sabuk DAN suspender. Kita tidak mau kehilangan keadaan disk.


Detail cerdik : satu commit saja

Kalau kamu commit di setiap perubahan berkas, kamu akan menumpuk ribuan commit. Dalam satu jam sesi, riwayat gitmu meledak. Repo menjadi raksasa. Itu menjijikkan.

Solusinya elegan : kita mengamend commit yang ada alih-alih membuat yang baru.

commit_and_push() {
  (
    flock -n 200 || return   # lock supaya dua save tidak jalan bersamaan

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # jangan sentuh skrip

    if ! git diff --cached --quiet; then
      if git rev-parse --verify HEAD >/dev/null 2>&1; then
        git commit --amend --no-edit    # AMEND : menimpa 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 punya satu commit. Tidak peduli berapa kali kamu menyimpan. Itu hanya snapshot keadaan saat ini, di force-push berulang-ulang.

flock itu kunci : karena ada dua loop save (inotify + berkala), harus mencegah mereka menjalankan git bersamaan dan saling menginjak. Satu proses git dalam satu waktu.

Bersih.


Sync_from_remote : menangani banyak sesi

Nah, kasus yang tidak terpikir di awal : bagaimana kalau kamu menjalankan DUA run bersamaan? Atau kalau satu sesi mengubah branch filesystem sementara yang lain berjalan?

Skrip menanganinya 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) ini penting : artinya "merge HANYA kalau bisa maju bersih, tanpa membuat commit merge".

Kalau kedua branch telah berdivergensi (misalnya, dua sesi mengubah berkas berbeda), fast-forward gagal diam-diam (2>/dev/null || true) dan kita menjaga keadaan lokal. Ini bukan sistem merge sempurna, tapi mencegah korupsi dalam kasus sederhana di mana satu sesi saja berjalan.

Sejujurnya, jangan menjalankan 3 sesi paralel di repo yang sama. Tapi kode tetap berusaha tidak meledak kalau itu terjadi. Ini pertahanan.


Cache APT : menginstal cepat

Ada detail di workflow yang tampak sepele tapi dipikirkan matang :

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

tmate dan inotify-tools dipasang lewat action yang meng cache paket APT.

Di run pertama, ia mengunduh dan memasang. Di run berikutnya, dipulihkan dari cache GitHub Actions -- lebih cepat, tidak perlu mengunduh ulang.

Dan kamu ingat git clean -fdx -e .apt-cache tadi? Itu terkait. Folder .apt-cache dilindungi dari pembersihan justru supaya paket yang kamu pasang selama sesimu bisa bertahan minimal.

Semuanya nyambung. Saya memikirkan siklus hidup lengkap.


Skrip yang disembunyikan di /tmp

Satu detail licik tapi cerdik lagi. Di awal skrip :

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

Skrip (update_readme.py, dll.) disalin ke /tmp SEBELUM menyentuh branch filesystem.

Kenapa? Karena ketika kamu melakukan git reset --hard ke branch filesystem (yang kosong di awal, atau yang berisi diskmu), berkas .github/scripts dari repo sumber hilang dari workspace.

Tapi skrip masih membutuhkannya selama sesi (untuk memperbarui README di setiap peluncuran ulang tmate). Jadi ia menyembunyikannya di /tmp, di luar jangkauan git :

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

Kalau tidak kepikiran, kamu menghabiskan 30 menit memahami kenapa skripmu hilang. Saya memikirkannya.


Shell sesuai pesanan

Kenyamanan kecil : sesi memberimu shell yang dikonfigurasi, bukan bash telanjang.

prestart.sh menyalin .bashrc kustom :

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 terutama override exit :

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

# Ctrl+D melakukan hal yang sama seperti exit
bind -x '"\C-d": "exit"'

Ketika kamu mengetik exit (atau Ctrl+D), ia membunuh bersih proses tmate sebelum menutup. Ini mencegah sesi tmate zombie tertinggal.

Ada juga fungsi tmate-detach kalau kamu ingin terputus TANPA membunuh sesi (untuk menyambung ulang nanti). Detail kenyamanan, tapi menunjukkan tingkat perhatian.


Tmate yang menyala ulang sendiri

Kenyamanan kecil : kalau kamu mengetik exit di shell, normalnya sesi tmate mati dan kamu terputus untuk selamanya.

Kecuali di sini, tmate ada 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? Sesi menyala ulang sendiri. Kamu menyambung ulang dengan tautan yang sama.

Ini bodoh, tapi membuat sesi bisa dipakai.


Penyambungan ulang dalam satu perintah

Bagaimana kamu menyambung ulang setelah terputus, tanpa mengobrak-abrik log run setiap kali?

Alamat SSH tmate ditulis dalam berkas host.conf, yang di commit ke branch filesystem :

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

Dan karena berkas ini ada di git, kamu bisa mengambilnya lewat 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 menjalankan itu, ia mencari alamat SSH saat ini di repo, dan menghubungkanmu. Bahkan kalau alamat berubah antara dua sesi.


Alur lengkap

Mari rekap :

  1. Kamu memicu workflow (push atau tombol manual)
  2. GitHub memberimu VM Ubuntu
  3. Skrip memulihkan disk dari branch "filesystem"
  4. inotify mulai mengawasi semua perubahan
  5. periodic_save commit setiap 5 detik sebagai cadangan
  6. tmate menyala → membangkitkan tautan SSH/web
  7. Tautan ditulis di README + host.conf
  8. Kamu terhubung dengan ssh atau terminal web
  9. Kamu melakukan apa pun yang kamu mau -- setiap perubahan berkas = autosave
  10. 6 jam kemudian, GitHub membunuh VM
  11. Diskmu utuh di branch "filesystem"
  12. Kamu menjalankan ulang workflow → kembali ke langkah 3, semuanya masih ada

VPS gratis dengan disk persisten. Hanya dengan git dan GitHub Actions.


Oke, harus jujur : batasannya

Ini hack, bukan VPS sungguhan. Jadi :

  • 6 jam maks per run. Harus menjalankan ulang workflow rutin. Uptime terbatas di setiap run.
  • Disediakan untuk dev dan eksplorasi. Kamu tidak akan menghosting situsmu di atasnya. Ini untuk menjelajah, dev, debug, menguji lingkungan Linux sekali pakai tapi bisa dipulihkan.
  • GitHub melihat segalanya. Ini mesin mereka. Jangan taruh yang sensitif.
  • Jaga repo tetap privat. Kamu mengekspos shell SSH. Repo publik = siapa pun berpotensi terhubung. Ide buruk.
  • Ini di batas syarat penggunaan. GitHub Actions dibuat untuk CI/CD, bukan untuk VPS gratis. Jadi pakai secukupnya, untuk yang sah, tanpa menyalahgunakan.

Tumit Achilles yang nyata : git benci berkas besar

Git dibuat untuk teks, bukan untuk filesystem.

Disk persisten hidup di branch git. Jadi semua yang kamu simpan lewat git. Dan git :

  • menangani buruk berkas biner besar (image Docker 2 GB di git? lupakan)
  • punya batas 100 MB per berkas di GitHub (batas keras, tidak bisa push lebih)
  • merekomendasikan tetap di bawah ~5 GB per repo

Jadi kalau kamu npm install proyek dengan 500 MB node_modules, atau kalau kamu build proyek yang membangkitkan biner berat, push ke filesystem akan entah ngadat parah, atau gagal total.

git commit --amend membantu (satu commit saja, tanpa riwayat yang menggembung), tapi tidak mengubah fakta bahwa berkas 200 MB tidak akan pernah lewat.

Intinya : ini bekerja super untuk kode, config, berkas kecil. Tidak bekerja untuk menyimpan data besar atau artefak biner. Harus diingat tentang apa yang kamu lakukan di sesimu.

Ini bukan snapshot sistem lengkap

Nuansa penting lain : branch filesystem menyimpan workspace (folder repo), bukan seluruh sistem.

Kalau kamu melakukan apt install htop, binernya masuk ke /usr/bin/htop, yang DI LUAR workspace. Jadi ia TIDAK akan disimpan. Di run berikutnya, harus menginstal ulang.

Makanya kita punya cache APT dan prestart.sh : untuk menyiapkan ulang lingkungan sistem di setiap startup, karena hanya workspace yang bertahan.

Kalau kamu ingin instalasimu bertahan, harus menaruhnya di workspace (misalnya, menginstal di folder lokal alih-alih sistem). Ini senam yang perlu diintegrasikan.


VPS gratis vs VPS sungguhan : pertandingan

repo-to-vps VPS sungguhan (5€/bulan)
Harga 0€ ~5-10€/bulan
Uptime 6 jam, harus dijalankan ulang 24/7
Disk branch git, berkas kecil SSD sungguhan, beberapa GB
RAM ~7 GB (murah hati!) 1-2 GB seringnya
CPU 2-4 core lumayan 1-2 vCPU
Setup clone template config manual
Persistensi workspace saja sistem lengkap
Legitimasi batas CGU 100% bersih

Poin menariknya adalah dari sisi spek mentah (RAM, CPU), runner GitHub sering LEBIH BAIK daripada VPS 5€. Tapi uptime 6 jam dan persistensi terbatas pada workspace, itulah yang menjadikannya mainan hacker, bukan server sungguhan.

Untuk belajar, menguji, men-debug sistem Linux cepat di lingkungan yang bisa dipulihkan? Sempurna. Untuk menghosting sesuatu yang serius? Ambil VPS sungguhan.

Tapi untuk lingkungan Linux sementara yang bisa kamu pulihkan sesukamu? Ini gila bagusnya.


Pola di balik semuanya

Kalau kamu mundur selangkah, repo-to-vps dan bot email (artikel saya yang lain) bersandar pada ide yang sama :

Git bukan sekadar pengelola versi. Ini sistem penyimpanan persisten, gratis, berversi, bisa diakses lewat API.

Begitu kamu punya sistem stateless (GitHub Actions, Worker, fungsi serverless) dan ingin menjaga keadaan antara dua eksekusi, git bisa berfungsi sebagai "disk".

  • Bot email menyimpan lastId di tag git.
  • repo-to-vps menyimpan filesystem utuh di branch git.

Pola yang sama, dua skala. Satu nilai di satu sisi, satu disk di sisi lain.

Dan git commit --amend + force-push adalah teknik umumnya : kamu menjaga satu commit yang mewakili keadaan saat ini, ditimpa di setiap pembaruan.

Ini tidak dibuat untuk itu. Tapi berhasil. Dan gratis.


3 poin yang perlu diingat :

  1. Satu branch git = satu hard disk persisten -- Simpan filesystem-mu di branch khusus, pulihkan saat startup, dan kamu punya keadaan yang bertahan dari mesin sekali pakai.

  2. inotify + git = autosave waktu nyata -- inotifywait mengawasi perubahan di tingkat kernel dan push ke git seketika. Dengan git commit --amend untuk menjaga satu commit bersih.

  3. tmate mengubah runner menjadi VPS -- SSH live di mesin GitHub Actions, dengan restart otomatis dan penyambungan ulang dalam satu perintah lewat API GitHub.

Git sebagai hard disk, episode kedua. Saya rasa saya akan berakhir menyimpan segalanya di branch git

Repo to VPS : GitHub Actions को स्थायी भंडारण वाले मुफ्त VPS में बदलना

git को स्थायी भंडारण बनाकर GitHub Actions runner को स्थायी VPS में कैसे बदलें, tmate, inotify और commit --amend।

GitHub तुम्हें 6 घंटे मुफ्त VPS देता है। मैंने उसे स्थायी बनाने का तरीका खोजा।

GitHub Actions तुम्हें मुफ्त Linux मशीनें देता है।

जैसे, असली Ubuntu serveur। 2 core, 7 Go RAM, 14 Go डिस्क। मुफ्त। प्रति run 6 घंटे।

एकमात्र "समस्या" : run खत्म होने पर, सब मिट जाता है। मशीन फेंकने योग्य है। तुम पैकेज स्थापित करते हो, तुम कोड करते हो, तुम कॉन्फ़िगर करते हो... और धड़ाम, अंत में सब गायब हो जाता है। जैसे तुमने कुछ किया ही नहीं।

सिवाय अगर।

सिवाय अगर तुम git को hard disk की तरह इस्तेमाल करो।

और वहाँ, अचानक, तुम्हारे पास स्थायी डिस्क वाला मुफ्त VPS है जो run बचता है। तुम फिर से जुड़ते हो, सब अभी भी वहाँ है। तुम वहीं फिर से शुरू करते हो जहाँ रुके थे।

यह पूरी तरह टूटा हुआ है। मुझे समझाने दो।


संदर्भ : GitHub Actions runner

जब तुम GitHub Actions workflow चलाते हो, तो GitHub तुम्हें VM देता है।

यह तुम्हारा कोड build करने, तुम्हारे परीक्षण चलाने, deploy करने के लिए बना है। workflow चलता है, अपना काम करता है, और मशीन नष्ट हो जाती है।

लेकिन कुछ भी तुम्हें इस VM से कुछ और करने से नहीं रोकता। जैसे, उस पर SSH shell खोलना और उसे serveur की तरह इस्तेमाल करना।

बाधा यह है कि ये मशीनें stateless और अस्थायी हैं :

  • अस्थायी : प्रति run अधिकतम 6h (timeout-minutes: 360, GitHub की छत)
  • Stateless : अंत में सब मिट जाता है

इसलिए उसे उपयोगी VPS बनाने के लिए, दो समस्याएँ सुलझानी होंगी :

  1. उस पर वास्तविक समय कैसे जुड़ें ?
  2. दो run के बीच डिस्क कैसे रखें ?

यहाँ यह गंदा hack बनता है।


समस्या 1 : tmate से live SSH

tmate tmux का fork है जो साझा करने योग्य SSH सत्र बनाता है।

तुम उसे मशीन पर चलाते हो, वह तुम्हें दो लिंक बनाता है :

तुम इन लिंक से एक से जुड़ते हो, और धड़ाम, तुम वास्तविक समय में मशीन पर shell में हो।

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}')

और ये लिंक Python स्क्रिप्ट से सीधे repo README में लिखे जाते हैं। तुम अपना repo खोलते हो, तुम कनेक्शन लिंक देखते हो, तुम क्लिक करते हो। तुम अपने VPS में हो।

पहली समस्या सुलझी। लेकिन दूसरी है जो सच में पागल है।


समस्या 2 : hard disk के रूप में git

यहाँ वह विचार है जो सब बदलता है।

मशीन हर run पर मिटती है। इसलिए हम filesystem को समर्पित 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 शाखा तुम्हारी hard disk है। तुम्हारी फाइलें, तुम्हारी install, तुम्हारी config, सब इसमें है।

विचार समझ रहे हो ? मशीन फेंकने योग्य है, लेकिन डिस्क git में रहती है। तुम workflow फिर से चलाते हो, डिस्क पुनर्स्थापित होती है, तुम ठीक वहीं फिर से शुरू करते हो जहाँ थे।

यह hibernate करने वाले VPS जैसा है। सिवाय इसके कि hibernation git repo है

पहला लॉन्च : खाली डिस्क बनाना

पहले 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 कुंजी है। अनाथ शाखा बिना इतिहास वाली शाखा है, जैसे तुम खाली repo से फिर से शुरू करते हो।

अनाथ क्यों ? क्योंकि तुम नहीं चाहते कि तुम्हारी स्थायी डिस्क तुम्हारे स्रोत कोड का पूरा इतिहास घसीटे। डिस्क अलग रहती है, अपने जीवन के साथ। वह खाली शुरू होती है।

और शुरुआत में git ls-remote --exit-code, यह बस साफ जाँच है : "क्या शाखा 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 → APT पैकेज cache (इस पर लौटेंगे, यह चालाक है)
  • .cache → सामान्य cache
  • host.conf → सत्र SSH पता
  • tmate.sock → चल रहे tmate सत्र का socket

अगर तुम ये फाइलें साफ करते, तो तुम सक्रिय सत्र तोड़ते या अपना cache खोते। इसलिए reset के दौरान उन्हें बचाते हैं।

पहली नजर में मूर्ख विवरण, लेकिन इसके बिना सब टूटता है।


autosave : inotify जो सब देखता है

अच्छा, लेकिन फाइलें filesystem शाखा में कैसे पहुँचती हैं ?

उत्तर : watcher जो सभी फाइल बदलाव देखता है और अपने आप commit/push करता है।

जादुई उपकरण inotifywait (inotify-tools पैकेज से) है। वह kernel स्तर पर filesystem देखता है और जैसे ही फाइल बदलती है trigger करता है।

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 flag चीड़ते हैं, क्योंकि हर एक मायने रखता है :

  • -r → पुनरावर्ती, सभी उप फोल्डर देखता है
  • -e modify,create,delete,move → इन 4 घटना प्रकारों पर प्रतिक्रिया करता है
  • --exclude '...' → कुछ फाइलें नजरअंदाज करने के लिए regex

--exclude महत्वपूर्ण है। देखो वह क्या नजरअंदाज करता है :

  • .git → जाहिर है, वरना हर commit autosave trigger करता जो commit trigger करता... अनंत लूप। तबाही।
  • .apt-cache और .cache → cache, जो हर समय बदलते हैं और जिन्हें हम git में spam नहीं करना चाहते
  • host.conf और tmate.sock → सत्र फाइलें, जो बिना रुके हिलती हैं
  • .gitignore, .txt.swp → अस्थायी फाइलें (.swp vim संपादन फाइलें हैं)

इस exclude के बिना, तुम्हें autosave मिलता जो अपने बदलावों पर लूप में trigger होता। सूची में .git, यही वह पंक्ति है जो तुम्हें पैर में गोली मारने से रोकती है।

तुम फाइल बदलते हो ? inotify उसे तुरंत पहचानता है, यह commit करता है, यह push करता है। एक सेकंड से कम में, तुम्हारा बदलाव filesystem शाखा में है।

तुम पैकेज स्थापित करते हो, तुम कोड लिखते हो, तुम config छूते हो, सब वास्तविक समय सहेजा जाता है, अपने आप, बिना तुम कुछ करो।

तुम्हारे पास सचमुच पूरे डिस्क की स्वचालित सहेजना तंत्र है। टूटा हुआ।

debounce : git spam नहीं करना

हर save बाद sleep 1 debounce है।

जब तुम एडिटर में फाइल सहेजते हो, तो अक्सर यह झटके में कई filesystem घटनाएँ बनाता है (अस्थायी फाइल निर्माण, rename, पुरानी हटाना...)। debounce के बिना, तुम एक ही सहेजने के लिए 3-4 commit trigger करते।

sleep 1 कहता है : "save बाद एक सेकंड प्रतीक्षा करो, जब तक झटका शांत हो, फिर से सुनने से पहले"। यह करीबी बदलावों को एक ही commit में समूहित करता है। चालाक।

और ऊपर से आवधिक सहेजना

अगर inotify कोई घटना चूक जाए, तो हर 5 सेकंड save भी है :

periodic_save() {
  while true; do
    sync_from_remote   # संभावित दूरस्थ बदलाव लेता है
    sleep 5
    commit_and_push
  done
}

periodic_save &

बेल्ट और सस्पेंडर। हम खासकर डिस्क स्थिति नहीं खोना चाहते।


चालाक विवरण : एक ही commit

अगर तुम हर फाइल बदलाव पर commit करते हो, तो तुम हजारों commit जमा करोगे। एक घंटे सत्र में, तुम्हारा git इतिहास फटता है। repo विशाल हो जाता है। यह गंदा है।

समाधान सुरुचिपूर्ण है : नया बनाने के बजाय मौजूदा commit संशोधित करते हैं।

commit_and_push() {
  (
    flock -n 200 || return   # lock ताकि दो save एक साथ न चलें

    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 है। चाहे तुम कितनी बार सहेजो। यह वर्तमान स्थिति का snapshot है, बार बार force-push किया हुआ।

flock ताला है : चूँकि save के दो लूप हैं (inotify + आवधिक), बचना होगा कि वे git एक साथ चलाकर एक दूसरे के पैर न खींचें। एक साथ एक ही git प्रक्रिया।

साफ।


sync_from_remote : कई सत्र प्रबंधित करना

खैर, एक मामला जिसके बारे में तुम शुरू में नहीं सोचते : और अगर तुम एक साथ दो 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) महत्वपूर्ण है : इसका मतलब "सिर्फ merge करो अगर हम merge commit बनाए बिना साफ आगे बढ़ सकते हैं"।

अगर दोनों शाखाएँ अलग हो गई हैं (जैसे, दो सत्रों ने अलग फाइलें बदलीं), तो fast-forward चुपचाप विफल होता है (2>/dev/null || true) और हम स्थानीय स्थिति रखते हैं। यह पूर्ण merge तंत्र नहीं है, लेकिन यह साधारण मामले में भ्रष्टाचार से रोकता है जहाँ एक ही सत्र चलता है।

ईमानदारी से, उसी repo पर 3 सत्र समानांतर नहीं चलाने चाहिए। लेकिन कोड फिर भी कोशिश करता है कि अगर ऐसा होता है तो न फटे। यह बचाव है।


APT cache : तेज स्थापित करना

workflow में एक विवरण है जो मामूली लगता है लेकिन अच्छी तरह सोचा है :

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

tmate और inotify-tools ऐसी क्रिया से स्थापित होते हैं जो APT पैकेज cache करती है।

पहले run पर, यह डाउनलोड और स्थापित करता है। अगले run पर, यह GitHub Actions cache से पुनर्स्थापित होता है, तेज, फिर से डाउनलोड की जरूरत नहीं।

और तुम्हें अभी का 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 में कॉपी की जाती हैं।

क्यों ? क्योंकि जब तुम filesystem शाखा की ओर git reset --hard करते हो (जो शुरू में खाली है, या जिसमें तुम्हारी डिस्क है), तो स्रोत repo की .github/scripts फाइलें workspace से गायब हो जाती हैं।

लेकिन स्क्रिप्ट को सत्र के दौरान अभी भी उनकी जरूरत है (tmate के हर पुनरारंभ पर README अद्यतन करने के लिए)। इसलिए वह उन्हें git पहुँच से बाहर /tmp में छिपाता है :

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

अगर तुम इसके बारे में नहीं सोचते, तो 30 मिनट जूझते हो यह समझने में कि तुम्हारी स्क्रिप्ट क्यों गायब हुई। मैंने इसके बारे में सोचा।


नाप का shell

छोटा आराम : सत्र तुम्हें कॉन्फ़िगर 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, उपनाम (ll, lla, rm -i), और खासकर exit override है :

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

# Ctrl+D exit जैसा करता है
bind -x '"\C-d": "exit"'

जब तुम exit टाइप करते हो (या Ctrl+D), तो वह बंद करने से पहले tmate प्रक्रियाएँ साफ मारता है। यह zombie tmate सत्र छोड़ने से रोकता है।

बिना सत्र मारे disconnect होने के लिए tmate-detach फंक्शन भी है (बाद में फिर से जुड़ने के लिए)। आराम विवरण, लेकिन यह देखभाल स्तर दिखाता है।


tmate जो खुद फिर से शुरू होता है

छोटा आराम : अगर तुम अपने shell में exit टाइप करते हो, तो सामान्यतः tmate सत्र मरता है और तुम अच्छे के लिए disconnect हो।

सिवाय यहाँ, 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 log में खोजे बिना, deco बाद कैसे फिर से जुड़ते हो ?

tmate का SSH पता host.conf फाइल में लिखा जाता है, filesystem शाखा में committ किया हुआ :

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. तुम workflow trigger करते हो (push या मैन्युअल बटन)
  2. GitHub तुम्हें Ubuntu VM देता है
  3. स्क्रिप्ट "filesystem" शाखा से डिस्क पुनर्स्थापित करती है
  4. inotify सभी बदलाव देखना शुरू करता है
  5. periodic_save backup में हर 5s commit करता है
  6. tmate शुरू होता है → SSH/web लिंक बनाता है
  7. लिंक README + host.conf में लिखे जाते हैं
  8. तुम ssh या web terminal से जुड़ते हो
  9. तुम जो चाहते हो करते हो, हर फाइल बदलाव = autosave
  10. 6 घंटे बाद, GitHub VM मारता है
  11. तुम्हारी डिस्क "filesystem" शाखा में अक्षुण्ण है
  12. तुम workflow फिर से चलाते हो → चरण 3 पर वापसी, सब अभी भी वहाँ है

स्थायी डिस्क वाला मुफ्त VPS। सिर्फ git और GitHub Actions से।


अच्छा, ईमानदार होना होगा : सीमाएँ

यह hack है, असली VPS नहीं। इसलिए :

  • प्रति run अधिकतम 6h। workflow नियमित फिर से चलाना होगा। हर run पर सीमित Uptime।
  • dev और खोज के लिए आरक्षित। तुम इस पर अपनी साइट host नहीं करोगे। यह खोजने, dev, debug, फेंकने योग्य लेकिन पुनः प्राप्त Linux वातावरण परखने के लिए है।
  • GitHub सब देखता है। यह उनकी मशीनें हैं। कुछ संवेदनशील मत रखो।
  • repo निजी रखो। तुम SSH shell उजागर करते हो। सार्वजनिक repo = कोई भी संभावित उससे जुड़ सकता है। बुरा विचार।
  • यह उपयोग शर्तों की सीमा पर है। GitHub Actions CI/CD के लिए बना है, मुफ्त VPS के लिए नहीं। इसलिए संयम से इस्तेमाल करो, वैध के लिए, दुरुपयोग के बिना।

असली Achilles एड़ी : git बड़ी फाइलों से नफरत करता है

Git पाठ के लिए बना है, filesystem के लिए नहीं।

स्थायी डिस्क git शाखा में रहती है। इसलिए जो कुछ तुम सहेजते हो वह git से गुजरता है। और git :

  • बड़ी बाइनरी फाइलें खराब संभालता है (git में 2 Go Docker छवि ? भूल जाओ)
  • GitHub पर प्रति फाइल 100 Mo सीमा है (कठोर सीमा, उसके परे push नहीं होता)
  • प्रति repo ~5 Go नीचे रहने की सिफारिश करता है

इसलिए अगर तुम 500 Mo node_modules वाला प्रोजेक्ट npm install करते हो, या तुम भारी binary बनाने वाला प्रोजेक्ट build करते हो, तो filesystem की ओर push या तो बहुत धीमा होगा, या पूरी तरह विफल होगा।

git commit --amend मदद करता है (एक ही commit, फूलता इतिहास नहीं), लेकिन यह तथ्य नहीं बदलता कि 200 Mo फाइल कभी नहीं जाएगी।

मोटे तौर पर : यह कोड, config, छोटी फाइलों के लिए बढ़िया काम करता है। यह बड़े डेटा या बाइनरी कलाकृतियाँ संग्रहित करने के लिए काम नहीं करता। अपने सत्र में जो करते हो उस पर इसे ध्यान में रखना होगा।

यह पूर्ण तंत्र snapshot नहीं है

एक और महत्वपूर्ण बारीकी : filesystem शाखा workspace (repo फोल्डर) सहेजती है, पूरा तंत्र नहीं।

अगर तुम apt install htop करते हो, तो binary /usr/bin/htop में जाता है, जो workspace से बाहर है। इसलिए वह सहेजा नहीं जाएगा। अगले run पर, फिर से स्थापित करना होगा।

इसीलिए हमारे पास APT cache और prestart.sh है : हर शुरू में तंत्र वातावरण फिर से तैयार करने के लिए, चूँकि सिर्फ workspace बना रहता है।

अगर तुम चाहते हो कि तुम्हारी install बचें, तो उन्हें workspace में रखना होगा (जैसे, तंत्र में बजाय स्थानीय फोल्डर में स्थापित करना)। यह एकीकृत करने की कसरत है।


मुफ्त VPS बनाम असली VPS : मैच

repo-to-vps असली VPS (5€/महीना)
कीमत 0€ ~5-10€/महीना
Uptime 6h, फिर से चलाना होगा 24/7
डिस्क git शाखा, छोटी फाइलें असली SSD, कई Go
RAM ~7 Go (उदार !) अक्सर 1-2 Go
CPU सही 2-4 core 1-2 vCPU
Setup template clone करो मैन्युअल config
स्थायित्व सिर्फ workspace पूरा तंत्र
वैधता CGU सीमा 100% साफ

दिलचस्प बिंदु यह है कि कच्चे spec तरफ (RAM, CPU), GitHub runner अक्सर 5€ VPS से बेहतर है। लेकिन 6h uptime और सिर्फ workspace स्थायित्व, यही इसे hacker खिलौना बनाता है, असली serveur नहीं।

सीखने, परखने, फेंकने योग्य लेकिन पुनः प्राप्त Linux वातावरण में Linux तंत्र vite fait debug करने के लिए ? एकदम सही। कुछ भी गंभीर host करने के लिए ? असली VPS लो।

लेकिन अस्थायी Linux वातावरण के लिए जिसे तुम इच्छा से पुनर्स्थापित कर सकते हो ? यह बस शानदार है।


इन सबके पीछे pattern

अगर तुम पीछे हटते हो, तो repo-to-vps और email बॉट (मेरा दूसरा लेख) उसी विचार पर टिके हैं :

Git सिर्फ संस्करण प्रबंधक नहीं है। यह स्थायी, मुफ्त, संस्करणबद्ध भंडारण तंत्र है, API से पहुँच योग्य।

जैसे ही तुम्हारे पास stateless तंत्र (GitHub Actions, Worker, serverless फंक्शन) है और तुम दो निष्पादनों के बीच स्थिति रखना चाहते हो, git "डिस्क" बन सकता है।

  • email बॉट git टैग में lastId संग्रहित करता है।
  • repo-to-vps पूरी filesystem git शाखा में संग्रहित करता है।

वही pattern, दो पैमाने। एक तरफ एक मान, दूसरी तरफ डिस्क।

और git commit --amend + force-push सामान्य तकनीक है : तुम एक ही commit रखते हो जो वर्तमान स्थिति दर्शाता है, हर अद्यतन पर मिटाया हुआ।

यह इसके लिए नहीं बना है। लेकिन काम करता है। और मुफ्त है।


याद रखने वाले 3 बिंदु :

  1. git शाखा = स्थायी hard disk , अपनी filesystem समर्पित शाखा में संग्रहित करो, शुरू में पुनर्स्थापित करो, और तुम्हारे पास स्थिति है जो फेंकने योग्य मशीनें बचती है।

  2. inotify + git = वास्तविक समय autosave , inotifywait kernel स्तर पर बदलाव देखता है और तुरंत git की ओर push करता है। साफ एकल commit रखने के लिए git commit --amend के साथ।

  3. tmate runner को VPS में बदलता है , GitHub Actions मशीन पर live SSH, स्वचालित restart और GitHub API से एक कमांड में पुनः जुड़ाव के साथ।

hard disk के रूप में Git, दूसरा episode। मुझे लगता है मैं सब कुछ git शाखाओं में संग्रहित करके खत्म करूँगा

Repo to VPS : تحويل GitHub Actions إلى VPS مجاني مع تخزين دائم

كيف تحوّل runner في GitHub Actions إلى VPS دائم مع git كمخزن دائم، tmate وinotify وcommit --amend.

تمنحك GitHub VPS مجانيا لـ6h. وجدت كيف أجعله دائما.

تمنحك GitHub Actions آلات Linux مجانية.

يعني، خوادم Ubuntu حقيقية. نواتان و7 Go ذاكرة و14 Go قرص. مجانا. لـ6h لكل تشغيل.

"المشكلة" الوحيدة : في نهاية التشغيل، يُمحَى كل شيء. الآلة قابلة للرمي. تثبت حزما وتبرمج وتضبط... وفجأة، في النهاية يختفي كل شيء. كأنك لم تفعل شيئا.

إلا إذا.

إلا إذا استخدمت git كقرص صلب.

وهناك، فجأة، لديك VPS مجاني مع قرص دائم ينجو من التشغيلات. تعيد الاتصال، وكل شيء ما زال هناك. تستأنف حيث توقفت.

إنه معطوب تماما. دعني أشرح لك.


السياق : runners الخاصة بـGitHub Actions

عندما تطلق workflow في GitHub Actions، تمنحك GitHub آلة VM.

إنها مصممة لبناء كودك وتشغيل اختباراتك والنشر. يعمل workflow ويؤدي عمله، وتُدمَّر الآلة.

لكن لا شيء يمنعك من فعل شيء آخر بهذه VM. مثلا، فتح shell عبر SSH عليها واستخدامها كخادم.

القيد أن هذه الآلات عديمة الحالة ومؤقتة :

  • مؤقتة : 6h حدا لكل تشغيل (timeout-minutes: 360، سقف GitHub)
  • عديمة الحالة : يُمحَى كل شيء في النهاية

إذن لجعلها VPS قابلة للاستخدام، يجب حل مشكلتين :

  1. كيف تتصل عليها في الوقت الحقيقي ؟
  2. كيف تحفظ القرص بين تشغيلين ؟

هنا يصبح hack قذرا.


المشكلة 1 : SSH المباشر مع tmate

tmate تفرع من tmux ينشئ جلسة SSH قابلة للمشاركة.

تشغّله على آلة، فيولد لك رابطين :

تتصل بأحد هذين الرابطين، وبوم، أنت في shell على الآلة. في الوقت الحقيقي.

إذن يطلق 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"

# استرجاع فرع 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

الإطلاق الأول : إنشاء القرص الفارغ

في أول تشغيل، لا يوجد فرع 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 في البداية مجرد فحص نظيف : "هل يوجد الفرع فعلا على البعيد ؟". إذا نعم، لا نلمس شيئا. وإذا لا، ننشئه. Idempotent، كما نحب.

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 ؟

الجواب : مراقب يراقب كل تغيرات الملفات ويُدخل/يدفع تلقائيا.

الأداة السحرية هي 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 إذا تغيرات كثيرة دفعة واحدة
  done
}

autosave &

لنفكك أعلام inotify، لأن كل واحد يهم :

  • -r ← تكراري، يراقب كل المجلدات الفرعية
  • -e modify,create,delete,move ← يتفاعل مع هذه الأنواع الأربعة من الأحداث
  • --exclude '...' ← تعبير نمطي لتجاهل بعض الملفات

--exclude حاسم. انظر ما يتجاهله :

  • .git ← بديهي، وإلا حفّز كل commit حفظا تلقائيا يحفّز commit... حلقة لانهائية. كارثة.
  • .apt-cache و.cache ← الكاشات، التي تتغير طوال الوقت ولا نريد إغراق git بها
  • host.conf وtmate.sock ← ملفا الجلسة، اللذان يتحركان بلا توقف
  • .gitignore و.txt.swp ← الملفات المؤقتة (ملفات .swp هي ملفات تحرير vim)

دون هذا الاستثناء، انتهيت بحفظ تلقائي يُحفَّز في حلقة على تغيراته الخاصة. وجود .git في القائمة هو السطر الذي يمنعك من إطلاق النار على قدمك.

تعدل ملفا ؟ يكشفه inotify فورا، فيُدخِل ويدفع. في أقل من ثانية، تغييرك في فرع filesystem.

تثبت حزمة وتكتب كودا وتلمس إعدادا، كل شيء يُحفَظ في الوقت الحقيقي، تلقائيا، دون أن تفعل شيئا.

لديك حرفيا نظام حفظ تلقائي للقرص كله. معطوب.

debounce : عدم إغراق git

sleep 1 بعد كل حفظ هو debounce.

عندما تحفظ ملفا في محرر، غالبا يولد ذلك عدة أحداث نظام ملفات دفعة (إنشاء ملف مؤقت وإعادة تسمية وحذف القديم...). دون debounce، حفّزت 3-4 commits لحفظ واحد.

يقول sleep 1 : "انتظر ثانية بعد حفظ، حتى تهدأ الدفعة، قبل إعادة الاستماع". وهذا يجمّع التغيرات القريبة في commit واحد. ذكي.

وحفظ دوري زيادة

تحسبا لفوات inotify حدثا، هناك أيضا حفظ كل 5 ثوانٍ :

periodic_save() {
  while true; do
    sync_from_remote   # يسترجع التغيرات البعيدة المحتملة
    sleep 5
    commit_and_push
  done
}

periodic_save &

حزام وحمالات. لا نريد خاصة فقدان حالة القرص.


التفصيل الذكي : commit واحد

إذا أدخلت مع كل تغير ملف، ستتراكم آلاف commits. في ساعة جلسة، ينفجر سجلك. يصبح المستودع ضخما. إنه قبيح.

الحل أنيق : نعدّل 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    # تعديل : يمحو 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 واحد. مهما حفظت. إنه فقط لقطة الحالة الراهنة، تُدفَع بالإجبار مرارا وتكرارا.

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 (تقديم سريع فقط) مهم : يعني "ادمج فقط إذا أمكن التقدم نظيفا، دون إنشاء commit دمج".

إذا تباعد الفرعان (مثلا، عدّلت جلستان ملفات مختلفة)، يفشل التقديم السريع بصمت (2>/dev/null || true) ونحتفظ بالحالة المحلية. ليس نظام دمج مثاليا، لكنه يتجنب الفساد في الحالة البسيطة حيث تعمل جلسة واحدة.

بصراحة، لا يجب إطلاق 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 الخاصة بالمستودع المصدري من مساحة العمل.

لكن السكربت ما زال يحتاجها أثناء الجلسة (لتحديث README مع كل إعادة إطلاق لـtmate). إذن يخبئها في /tmp، بعيدا عن متناول git :

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

إذا لم تفكر في ذلك، تقضي 30 دقيقة لتفهم لماذا اختفى سكربتك. فكرت في ذلك.


shell المصمم

راحة صغيرة : تمنحك الجلسة 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 ملونا وأسماء مستعارة (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 قبل الإغلاق. وهذا يتجنب ترك جلسات tmate زومبي.

وهناك أيضا دالة tmate-detach إذا أردت قطع الاتصال دون قتل الجلسة (لإعادة الاتصال لاحقا). تفصيل راحة، لكنه يظهر مستوى العناية.


tmate الذي يعيد إطلاق نفسه

راحة صغيرة : إذا كتبت exit في shell خاصتك، عادة تموت جلسة 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 ؟ تعيد الجلسة التشغيل وحدها. تعيد الاتصال بنفس الرابط.

إنه غبي، لكنه يجعل الجلسة قابلة للاستخدام.


إعادة الاتصال بأمر واحد

كيف تعيد الاتصال بعد قطع، دون التنقيب في سجلات التشغيل كل مرة ؟

يُكتَب عنوان 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 (دفع أو زر يدوي)
  2. تمنحك GitHub آلة VM بنظام Ubuntu
  3. يستعيد السكربت القرص من فرع "filesystem"
  4. يبدأ inotify مراقبة كل التغيرات
  5. يحفظ periodic_save كل 5s كنسخة احتياطية
  6. ينطلق tmate ← يولد روابط SSH/ويب
  7. تُكتَب الروابط في README + host.conf
  8. تتصل عبر ssh أو الطرفية الويب
  9. تفعل ما تريد، كل تغير ملف = حفظ تلقائي
  10. بعد 6h، تقتل GitHub الآلة VM
  11. قرصك سليم في فرع "filesystem"
  12. تعيد إطلاق workflow ← عودة للخطوة 3، وكل شيء ما زال هناك

VPS مجاني مع قرص دائم. فقط مع git وGitHub Actions.


حسنا، لنكن صادقين : الحدود

إنه hack، لا VPS حقيقي. إذن :

  • 6h حدا لكل تشغيل. يجب إعادة إطلاق workflow بانتظام. Uptime محدود لكل تشغيل.
  • مخصص للتطوير والاستكشاف. لن تستضيف موقعك عليه. إنه للاستكشاف والتطوير والتصحيح واختبار بيئة Linux قابلة للرمي لكن قابلة للاستعادة.
  • ترى GitHub كل شيء. إنها آلاتها. لا تضع شيئا حساسا.
  • اجعل المستودع خاصا. تعرض shell عبر SSH. مستودع عام = يمكن لأي كان الاتصال به احتمالا. فكرة سيئة.
  • إنه على حد شروط الاستخدام. GitHub Actions مصمم لـCI/CD، لا لـVPS مجاني. إذن استخدمه باعتدال، ولشيء مشروع، دون إساءة.

العقبة الحقيقية : git يكره الملفات الكبيرة

صُمِّم git للنص، لا لنظام ملفات.

يعيش القرص الدائم في فرع git. إذن كل ما تحفظه يمر عبر git. وgit :

  • يدير سيئا الملفات الثنائية الكبيرة (صورة Docker من 2 Go في git ؟ انسَ)
  • له حد 100 Mo لكل ملف على GitHub (حد صلب، لا يدفع بعده)
  • يوصي بالبقاء تحت ~5 Go لكل مستودع

إذن إذا نفذت npm install لمشروع مع 500 Mo من node_modules، أو بنيت مشروعا يولد ثنائيات ثقيلة، فالدفع نحو filesystem سيعمل ببطء شديد أو سيفشل تماما.

يساعد git commit --amend (commit واحد، دون سجل ينتفخ)، لكنه لا يغيّر أن ملفا من 200 Mo لن يمر أبدا.

باختصار : يعمل رائعا للكود والإعدادات والملفات الصغيرة. ولا يعمل لتخزين بيانات ضخمة أو منتجات ثنائية. يجب إبقاء ذلك في الذهن لما تفعله في جلستك.

ليس لقطة نظام كاملة

فارق مهم آخر : يحفظ فرع filesystem مساحة العمل (مجلد المستودع)، لا النظام كله.

إذا نفذت apt install htop، يذهب الثنائي إلى /usr/bin/htop، وهو خارج مساحة العمل. إذن لن يُحفَظ. في التشغيل التالي، يجب إعادة تثبيته.

لهذا لدينا كاش APT وprestart.sh : لإعادة تجهيز بيئة النظام مع كل إقلاع، بما أن مساحة العمل وحدها تدوم.

إذا أردت أن تنجو تثبيتاتك، يجب وضعها في مساحة العمل (مثلا، التثبيت في مجلد محلي بدلا من النظام). إنها رياضة يجب دمجها.


VPS مجاني ضد VPS حقيقي : المباراة

repo-to-vps VPS حقيقي (5€/شهر)
السعر 0€ ~5-10€/شهر
Uptime 6h، يُعاد إطلاقه 24/7
القرص فرع git، ملفات صغيرة SSD حقيقي، عدة Go
RAM ~7 Go (سخية !) 1-2 Go غالبا
CPU 2-4 أنوية صحيحة 1-2 vCPU
الإعداد استنساخ قالب إعداد يدوي
الثبات مساحة عمل فقط نظام كامل
الشرعية على حد شروط الاستخدام نظيف 100%

النقطة المثيرة أن المواصفات الخام جهة (RAM وCPU)، runner الخاص بـGitHub أفضل غالبا من VPS بـ5€. لكن uptime الـ6h والثبات المحدود بمساحة العمل هو ما يجعله لعبة هاكر، لا خادما حقيقيا.

للتعلم والاختبار وتصحيح نظام Linux سريعا في بيئة قابلة للاستعادة ؟ مثالي. لاستضافة أي شيء جدي ؟ خذ VPS حقيقي.

لكن لبيئة Linux مؤقتة تستطيع استعادتها متى شئت ؟ إنه رائع فقط.


النمط خلف كل ذلك

إذا تراجعت، يرتكز repo-to-vps وبوت البريد (مقالي الآخر) على نفس الفكرة :

ليس git مدير إصدارات فقط. إنه نظام تخزين دائم ومجاني ومُصدَر، وقابل للوصول عبر API.

بمجرد أن يكون لديك نظام عديم الحالة (GitHub Actions أو Worker أو دالة serverless) وتريد الاحتفاظ بحالة بين تنفيذين، يمكن أن يعمل git "قرصا".

  • يخزن بوت البريد lastId في وسم git.
  • يخزن repo-to-vps نظام ملفات كاملا في فرع git.

نفس النمط، ومقياسان. قيمة من جهة، وقرص من جهة.

وتقنية git commit --amend + الدفع بالإجبار هي التقنية المشتركة : تحتفظ بـcommit واحد يمثل الحالة الراهنة، يُمحَى مع كل تحديث.

ليس مصمما لذلك. لكنه يعمل. وهو مجاني.


النقاط الثلاث للتذكر :

  1. فرع git = قرص صلب دائم، خزّن نظام ملفاتك في فرع مخصص، واستعده عند الإقلاع، ولديك حالة تنجو من الآلات القابلة للرمي.

  2. inotify + git = حفظ تلقائي في الوقت الحقيقي، يراقب inotifywait التغيرات في مستوى النواة ويدفع نحو git فورا. مع git commit --amend للاحتفاظ بـcommit واحد نظيف.

  3. يحوّل tmate runner إلى VPS، SSH مباشر على آلة GitHub Actions، مع إعادة تشغيل آلية وإعادة اتصال بأمر واحد عبر GitHub API.

git كقرص صلب، الحلقة الثانية. أظن أنني سأنتهي بتخزين كل شيء في فروع git

Repo thành VPS : biến GitHub Actions thành VPS miễn phí với lưu trữ bền vững

Cách biến runner GitHub Actions thành VPS vĩnh viễn với git làm lưu trữ bền vững -- tmate, inotify và commit --amend.

GitHub cho bạn VPS miễn phí 6 giờ. Tôi đã tìm ra cách khiến nó vĩnh viễn.

GitHub Actions cho bạn máy Linux miễn phí.

Kiểu, máy chủ Ubuntu thật. 2 lõi, 7 Go RAM, 14 Go đĩa. Miễn phí. 6 giờ mỗi lần chạy.

"Vấn đề" duy nhất : cuối lần chạy, mọi thứ bị xóa. Máy dùng một lần. Bạn cài gói, bạn code, bạn cấu hình... và pouf, cuối cùng mọi thứ biến mất. Như thể bạn chưa làm gì.

Trừ khi.

Trừ khi bạn dùng git làm ổ cứng.

Và lúc đó, đùng cái, bạn có VPS miễn phí với đĩa bền vững sống sót qua các lần chạy. Bạn kết nối lại, mọi thứ còn đó. Bạn tiếp tục nơi đã dừng.

Hoàn toàn điên. Để tôi giải thích.


Bối cảnh : runner GitHub Actions

Khi bạn chạy workflow GitHub Actions, GitHub đưa bạn VM.

Nó sinh ra để build mã, chạy test, deploy. Workflow chạy, làm việc, và máy bị hủy.

Nhưng không gì cấm bạn làm chuyện khác với VM này. Kiểu, mở shell SSH lên đó và dùng nó như máy chủ.

Ràng buộc là các máy này stateless và tạm thời :

  • Tạm thời : tối đa 6 giờ mỗi lần chạy (timeout-minutes: 360, trần của GitHub)
  • Stateless : mọi thứ bị xóa cuối cùng

Nên để biến nó thành VPS dùng được, phải giải hai bài toán :

  1. Kết nối lên đó thời gian thực thế nào?
  2. Giữ đĩa giữa hai lần chạy thế nào?

Tới đây thành mẹo bẩn.


Bài toán 1 : SSH trực tiếp bằng tmate

tmate là fork của tmux tạo phiên SSH chia sẻ được.

Bạn chạy nó trên máy, nó sinh hai link :

Bạn kết nối bằng một trong các link này, và bùm, bạn ở trong shell trên máy. Thời gian thực.

Workflow vì vậy 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 link 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à các link này được ghi thẳng vào README của repo bằng script Python. Bạn mở repo, thấy link kết nối, bấm. Bạn ở trong VPS của mình.

Xong bài toán đầu. Nhưng bài thứ hai mới thực sự điên.


Bài toán 2 : git làm ổ cứng

Đây là ý tưởng thay đổi tất cả.

Máy bị xóa mỗi lần chạy. Nên ta lưu hệ thống tệp trong nhánh git chuyên dụng, tên là filesystem.

Lúc 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ài đặt, cấu hình của bạn -- tất cả trong đó.

Bạn thấy ý tưởng chứ? Máy dùng một lần, nhưng đĩa sống trong git. Bạn chạy lại workflow, đĩa được khôi phục, bạn tiếp tục đúng nơi đã dừng.

Như VPS ngủ đông. Chỉ có điều ngủ đông là repo git.

Lần chạy đầu : tạo đĩa trống

Ngay lần chạy đầu, nhánh filesystem chưa tồn tại. Phải tạo. Và việc này không tầm thường :

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. Nhánh mồ côi là nhánh không có lịch sử nào -- như thể bạn khởi đầu từ repo trống.

Vì sao mồ côi? Vì bạn KHÔNG muốn đĩa bền vững lôi theo toàn bộ lịch sử mã nguồn. Đĩa ở riêng, sống đời riêng. Nó khởi đầu trinh nguyên.

Và git ls-remote --exit-code ở đầu, chỉ là kiểm tra sạch : "nhánh đã tồn tại trên remote chưa?". Nếu có, không đụng gì. Nếu chưa, ta tạo. Idempotent, như 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 MỌI thứ git không theo dõi. Bình thường việc này bạo lực -- nó dọn workspace sạch bong.

Nhưng các -e (loại trừ) bảo vệ vài tệp :

  • .apt-cache → cache gói APT (ta sẽ quay lại, nó láu)
  • .cache → cache chung
  • host.conf → địa chỉ SSH của phiên
  • tmate.sock → socket của phiên tmate đang chạy

Nếu bạn dọn các tệp đó, bạn phá phiên đang chạy hay mất cache. Nên ta tha chúng lúc reset.

Chi tiết trông ngu lúc đầu, nhưng thiếu nó mọi thứ vỡ.


Tự lưu : inotify canh mọi thứ

Được, nhưng tệp vào nhánh filesystem bằng cách nào?

Trả lời : watcher canh MỌI thay đổi tệp và commit/đẩy tự động.

Công cụ kỳ diệu là inotifywait (từ gói inotify-tools). Nó canh filesystem ở tầng kernel và kích hoạt hễ tệp đổ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 nhiều thay đổi dồn dập
  done
}

autosave &

Mổ các cờ inotify, vì cờ nào cũng đáng :

  • -r → đệ quy, canh mọi thư mục con
  • -e modify,create,delete,move → phản ứng với 4 kiểu sự kiện này
  • --exclude '...' → regex để bỏ qua vài tệp

--exclude then chốt. Xem nó bỏ qua gì :

  • .git → hiển nhiên, nếu không mỗi commit kích hoạt autosave kích hoạt commit... vòng lặp vô hạn. Thảm họa.
  • .apt-cache và .cache → cache, đổi liên tục mà ta không muốn spam vào git
  • host.conf và tmate.sock → tệp phiên, động không ngừng
  • .gitignore, .txt.swp → tệp tạm (.swp là tệp chỉnh sửa của vim)

Không có exclude này, bạn sẽ có autosave tự kích hoạt vòng lặp trên chính thay đổi của nó. .git trong danh sách, chính là dòng ngăn bạn tự bắn vào chân.

Bạn sửa tệp? inotify phát hiện tức thì, nó commit, nó đẩy. Dưới một giây, thay đổi của bạn đã vào nhánh filesystem.

Bạn cài gói, bạn viết mã, bạn đụng config -- mọi thứ được lưu thời gian thực, tự động, mà bạn không phải làm gì.

Bạn đúng nghĩa có hệ thống tự lưu toàn bộ đĩa. Điên.

Debounce : không spam git

sleep 1 sau mỗi lần lưu là debounce.

Khi bạn lưu tệp trong trình chỉnh, thường nó sinh nhiều sự kiện filesystem dồn dập (tạo tệp tạm, đổi tên, xóa cái cũ...). Không debounce, bạn kích 3-4 commit cho một lần lưu.

sleep 1 nói : "chờ một giây sau lần lưu, cho đợt dồn lắng xuống, rồi mới nghe lại". Việc này gom thay đổi gần nhau thành một commit. Láu.

Và thêm lưu định kỳ

Phòng khi inotify hụt sự kiện, còn lưu mỗi 5 giây :

periodic_save() {
  while true; do
    sync_from_remote   # lấy thay đổi từ xa nếu có
    sleep 5
    commit_and_push
  done
}

periodic_save &

Cả thắt lưng VÀ dây đeo. Ta nhất quyết không muốn mất trạng thái đĩa.


Chi tiết láu : một commit duy nhất

Nếu bạn commit mỗi lần tệp đổi, bạn sẽ tích hàng nghìn commit. Trong một giờ phiên, lịch sử git nổ tung. Repo thành khổng lồ. Kinh tởm.

Giải pháp thanh lịch : sửa commit hiện có thay vì tạo commit mới.

commit_and_push() {
  (
    flock -n 200 || return   # khóa để hai lần lưu không chạy cùng lúc

    git add -A
    git reset -- .github/workflows/ .github/scripts/   # không đụng script

    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 commit cuối bằng cái này".

Nhờ vậy nhánh filesystem LUÔN chỉ một commit. Dù bạn lưu bao nhiêu lần. Nó chỉ là snapshot trạng thái hiện tại, force-push đi đẩy lại.

flock là khóa : vì có hai vòng lưu (inotify + định kỳ), phải tránh chúng chạy git cùng lúc và giẫm nhau. Một tiến trình git tại một thời điểm.

Gọn.


sync_from_remote : xử lý nhiều phiên

Này, trường hợp bạn lúc đầu không nghĩ tới : nếu bạn chạy HAI lần cùng lúc thì sao? Hay một phiên sửa nhánh filesystem trong khi phiên khác chạy?

Script xử lý việc đó 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 (chỉ fast-forward) quan trọng : nghĩa là "merge CHỈ nếu tiến sạch được, không tạo commit merge".

Nếu hai nhánh đã rẽ (kiểu, hai phiên sửa các tệp khác nhau), fast-forward thất bại lặng lẽ (2>/dev/null || true) và ta giữ trạng thái cục bộ. Không phải hệ thống merge hoàn hảo, nhưng tránh hỏng trong trường hợp đơn giản chỉ một phiên chạy.

Thật lòng, đừng chạy 3 phiên song song trên cùng repo. Nhưng mã vẫn cố không nổ nếu chuyện đó xảy ra. Đó là phòng thủ.


Cache APT : cài nhanh

Có chi tiết trong workflow trông không có gì mà nghĩ kỹ :

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

tmate và inotify-tools được cài qua action cache gói APT.

Lần chạy đầu, nó tải và cài. Các lần sau, nó 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 chứ? Liên quan đấy. Thư mục .apt-cache được bảo vệ khỏi dọn dẹp đúng để các gói bạn cài trong phiên có thể bền tối thiểu.

Mọi thứ khớp. Tôi đã nghĩ tới toàn bộ vòng đời.


Script giấu trong /tmp

Thêm chi tiết hiểm mà láu. 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 chép vào /tmp TRƯỚC khi đụng tới nhánh filesystem.

Vì sao? Vì khi bạn làm git reset --hard về nhánh filesystem (ban đầu trống, hay chứa đĩa của bạn), các tệp .github/scripts của repo nguồn biến mất khỏi workspace.

Nhưng script vẫn cần chúng trong phiên (để cập nhật README mỗi lần tmate chạy lại). Nên nó giấu chúng trong /tmp, ngoài tầm git :

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

Nếu bạn không nghĩ tới, bạn vật vã 30 phút để hiểu vì sao script biến mất. Tôi đã nghĩ tới.


Shell đo ni đóng giày

Tiện nghi nhỏ : phiên cho bạn shell đã cấu hình, không phải bash trần.

prestart.sh chép .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

Và .bashrc này chứa prompt màu, alias (ll, lla, rm -i), và nhất là ghi đè exit :

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

# Ctrl+D làm giống exit
bind -x '"\C-d": "exit"'

Khi bạn gõ exit (hay Ctrl+D), nó giết sạch tiến trình tmate trước khi đóng. Việc này tránh để lại phiên tmate zombie.

Ngoài ra có hàm tmate-detach nếu bạn muốn ngắt KẾT NỐI mà KHÔNG giết phiên (để 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ự chạy lại

Tiện nghi nhỏ : nếu bạn gõ exit trong shell, bình thường phiên tmate chết và bạn mất kết nối vĩnh viễn.

Chỉ có điều ở đây, tmate nằm trong vòng 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? Phiên tự khởi động lại. Bạn kết nối lại bằng cùng link.

Ngớ ngẩn, nhưng khiến phiên dùng được.


Kết nối lại bằng một lệnh

Bạn kết nối lại sau khi mất kết nối thế nào, mà không phải lục log lần chạy mỗi lần?

Địa chỉ SSH của tmate được ghi vào tệp host.conf, commit trong nhánh filesystem :

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

Và vì tệp này trong git, bạn lấy được 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 nó, nó đi lấy địa chỉ SSH hiện tại trong repo, và kết nối bạn. Ngay cả khi địa chỉ đã đổi giữa hai phiên.


Luồng đầy đủ

Tóm lại :

  1. Bạn kích hoạt workflow (đẩy hay nút tay)
  2. GitHub đưa bạn VM Ubuntu
  3. Script khôi phục đĩa từ nhánh "filesystem"
  4. inotify bắt đầu canh mọi thay đổi
  5. periodic_save commit mỗi 5s để dự phòng
  6. tmate khởi động → sinh link SSH/web
  7. Link được ghi vào README + host.conf
  8. Bạn kết nối bằng ssh hay terminal web
  9. Bạn làm gì tùy -- mỗi thay đổi tệp = tự lưu
  10. 6 giờ sau, GitHub giết VM
  11. Đĩa của bạn nguyên vẹn trong nhánh "filesystem"
  12. Bạn chạy lại workflow → về bước 3, mọi thứ còn đó

VPS miễn phí với đĩa bền vững. Chỉ bằng git và GitHub Actions.


Được, phải trung thực : giới hạn

Đây là mẹo, không phải VPS thật. Nên :

  • Tối đa 6 giờ mỗi lần chạy. Phải chạy lại workflow thường xuyên. Uptime giới hạn mỗi lần chạy.
  • Dành cho dev và khám phá. Bạn sẽ không host trang của mình trên đó. Nó để khám phá, dev, gỡ lỗi, test môi trường Linux dùng một lần mà khôi phục được.
  • GitHub thấy hết. Đó là máy của họ. Đừng đặt gì nhạy cảm.
  • Giữ repo riêng tư. Bạn phơi shell SSH. Repo công khai = bất kỳ ai cũng có thể kết nối vào. Ý tệ.
  • Nó ở rìa điều khoản dùng. GitHub Actions sinh ra cho CI/CD, không phải VPS miễn phí. Nên dùng tiết chế, cho việc hợp lệ, không lạm dụng.

Gót chân Achilles thật : git ghét tệp lớn

Git sinh ra cho văn bản, không phải filesystem.

Đĩa bền vững sống trong nhánh git. Nên mọi thứ bạn lưu đều qua git. Và git :

  • xử lý kém tệp nhị phân lớn (ảnh Docker 2 Go trong git? quên đi)
  • có giới hạn 100 Mo cho mỗi tệp trên GitHub (giới hạn cứng, không đẩy được vượt)
  • khuyến nghị ở dưới ~5 Go cho mỗi repo

Nên nếu bạn npm install dự án với 500 Mo node_modules, hay build dự án sinh nhị phân nặng, lần đẩy lên filesystem sẽ hoặc ì ạch tới chết, hoặc thất bại thẳng.

git commit --amend giúp (một commit duy nhất, không lịch sử phình), nhưng không đổi được việc tệp 200 Mo sẽ không bao giờ qua.

Đại khái : nó chạy siêu tốt cho mã, config, tệp nhỏ. Nó không chạy để lưu dữ liệu lớn hay artefact nhị phân. Phải nhớ điều đó về những gì bạn làm trong phiên.

Không phải snapshot hệ thống đầy đủ

Sắc thái quan trọng khác : nhánh filesystem lưu workspace (thư mục repo), không phải toàn bộ hệ thống.

Nếu bạn làm apt install htop, nhị phân vào /usr/bin/htop, nằm NGOÀI workspace. Nên nó sẽ KHÔNG được lưu. Lần chạy sau, phải cài lại.

Đó là lý do ta 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 bền vững.

Nếu bạn muốn cài đặt của mình sống sót, phải đặt chúng trong workspace (kiểu, cài vào thư mục cục bộ thay vì hệ thống). Đó là bài thể dục cần nhập tâm.


VPS miễn phí so với VPS thật : trận đấu

repo-to-vps VPS thật (5€/tháng)
Giá 0€ ~5-10€/tháng
Uptime 6 giờ, phải chạy lại 24/7
Đĩa nhánh git, tệp nhỏ SSD thật, nhiều Go
RAM ~7 Go (hào phóng!) Thường 1-2 Go
CPU 2-4 lõi ổn 1-2 vCPU
Setup clone mẫu cấu hình tay
Bền vững chỉ workspace toàn bộ hệ thống
Hợp lệ rìa CGU 100% sạch

Điểm thú vị là về spec thô (RAM, CPU), runner GitHub thường TỐT HƠN VPS 5€. Nhưng uptime 6 giờ và bền vững giới hạn ở workspace, đó là thứ biến nó thành đồ chơi hacker, không phải máy chủ thật.

Để học, test, gỡ lỗi hệ thống Linux nhanh trong môi trường khôi phục được? Hoàn hảo. Để host thứ gì nghiêm túc? Lấy VPS thật.

Nhưng với môi trường Linux tạm thời mà bạn có thể khôi phục tùy ý? Đúng là thiên tài.


Mẫu đằng sau tất cả

Nếu bạn lùi lại, repo-to-vps và bot email (bài kia của tôi) dựa trên cùng ý tưởng :

Git không chỉ là quản lý phiên bản. Đó là hệ thống lưu trữ bền vững, miễn phí, có phiên bản, truy cập được qua API.

Hễ bạn có hệ thống stateless (GitHub Actions, Worker, hàm serverless) mà muốn giữ trạng thái giữa hai lần thực thi, git có thể đóng vai "đĩa".

  • Bot email lưu lastId trong tag git.
  • repo-to-vps lưu cả filesystem trong nhánh git.

Cùng mẫu, hai quy mô. Một giá trị một bên, một đĩa một bên.

Và git commit --amend + force-push là kỹ thuật chung : bạn giữ một commit duy nhất đại diện trạng thái hiện tại, ghi đè mỗi lần cập nhật.

Nó không sinh ra cho việc đó. Nhưng nó chạy. Và miễn phí.


3 điểm cần nhớ :

  1. Nhánh git = ổ cứng bền vững -- Lưu filesystem vào nhánh chuyên dụng, khôi phục lúc khởi động, và bạn có trạng thái sống sót qua máy dùng một lần.

  2. inotify + git = tự lưu thời gian thực -- inotifywait canh thay đổi ở tầng kernel và đẩy lên git tức thì. Với git commit --amend để giữ một commit sạch duy nhất.

  3. tmate biến runner thành VPS -- SSH trực tiếp lên máy GitHub Actions, với khởi động lại tự động 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ĩ mình sẽ lưu mọi thứ vào nhánh git mất

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