GitHub avatar

Fox's Blog

5 ingenious ways to use GitHub Actions (and what they teach about secrets)

A CI runner turned into a free VPS, a bot that opens its own pull requests, an npm publish with zero secrets. A tour of my repos to catalog GitHub Actions patterns that go beyond \"lint + test + deploy\".

5 ingenious ways to use GitHub Actions

On paper, GitHub Actions is for classic CI/CD: you push, it lints, tests, deploys. I've already written about a specific case -- using git tags as a database for an email bot (see the dedicated article). But digging through my own repos, there are enough different patterns that it's worth a standalone article, less focused on a single project, more like a catalog of techniques.

Five things, from the most classic to the most twisted.

1. A git tag as persistent state between runs

Quick recap, the full details are in the email-autoreply article. GitHub Actions is stateless by design -- each run starts from a blank machine. The workaround: store a value (an ID, a timestamp, any small piece of state) in a dedicated git tag, never in a branch.

# read state
git show refs/tags/lastid:data/lastId > data/lastId

# write state (orphan branch, single commit, force-push the tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

The key point: an orphan branch to never accumulate history, and a forced tag rather than a branch so you don't pollute the repo's branch list.

2. A git tag as a precompiled build cache

Same family of ideas, different use: instead of storing application state, you store a build artifact. The build job compiles the code once (on push to master), then pushes dist/ + node_modules/ into a runtime tag. The cron job checks out that tag directly instead of running bun install && bun run build every execution:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# no install, no build -- the code is ready
- run: node dist/index.js --action

That changes the run from ~20s to ~10s. On a cron that runs often, it matters. actions/cache does a similar job (caching dependencies), but a git tag is more direct when you want to freeze a versioned artifact entirely and point to it explicitly -- not just speed up an npm install.

3. A single required check that aggregates multiple jobs

A small pattern that doesn't look like much but changes everything in branch protection config. On konosuba-rpg, the CI has three independent jobs (typecheck, lint, tests) running in parallel -- and a fourth job, test-battery, that does nothing but depend on the first three:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Without this facade job, configuring a protected branch would require checking three separate mandatory checks -- and updating that list every time a job is added or renamed. With test-battery, a single name to check in repo settings, which stays stable even if the internal details change.

4. Turning a free runner into a temporary VPS

This one is the most twisted of all, and clearly my favorite: repo-to-vps completely hijacks the intended use of a GitHub Actions runner to turn it into a Linux machine accessible via SSH, free, for up to 6 hours (the max duration of a job).

The principle: a job that does almost nothing but launch tmate (a tmux fork that exposes a shareable terminal session via SSH or browser):

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

The real headache is that a GitHub Actions runner's filesystem is disposable -- as soon as the job ends, everything disappears. An SSH session that lasts hours is useless if everything you do evaporates on the next run. The solution: a git branch that serves as a live snapshot of the filesystem, continuously synchronized.

The start-tmate.sh script does, in order:

  1. Restores the filesystem from a dedicated filesystem branch at job startup (git reset --hard onto it).
  2. Watches file changes continuously with inotifywait, and commits + pushes immediately whenever a file moves:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1   # debounce
  done
}
  1. Each save amends the previous commit rather than creating a new one (git commit --amend --no-edit), so the filesystem branch always stays at a single commit -- no accumulation of thousands of snapshots.
  2. A while true loop restarts tmate automatically if the session dies, with remain-on-exit on so the terminal stays reachable even after an exit.
  3. The SSH URL generated by tmate is written to a host.conf file, committed to the filesystem branch -- retrievable via the GitHub API (gh api .../contents/host.conf) without ever having had live access to the job's logs.
  4. A periodic_save routine runs every 5 seconds in the background, in case inotifywait misses an event.

Result: a full Linux shell, accessible from anywhere, with a filesystem that persists between sessions -- even though the underlying infrastructure (a GitHub Actions runner) was absolutely not designed for this. The only real limit is the 6-hour job timeout -- after which you have to restart the workflow.

5. A bot that opens its own pull requests

On konosuba-rpg, a push to the dev branch triggers a job that checks whether an open pull request to main already exists -- and creates one automatically if not, via actions/github-script and the GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return; // nothing to propose

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return; // PR already open

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

The detail that matters here is the token used. This workflow does not use the automatic GITHUB_TOKEN -- it requires a separate AUTO_PR_TOKEN secret, and refuses to continue if it's missing:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Publishing to npm with zero secrets

The quietest of the five, but probably the most important for the future: the publish.yml workflow of typescript-virtual-container contains no npm secrets. No NPM_TOKEN, no NODE_AUTH_TOKEN. Just this:

permissions:
  id-token: write  # Required for OIDC
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish still works, because the npm registry now supports trusted publishing via OIDC: the workflow proves its identity directly to the registry (exact repo + exact workflow, configured on the npmjs.org side), without any static token transiting or being stored anywhere. Zero secrets to leak, zero tokens to rotate every six months.


GitHub secrets, in depth

These five patterns all touch, in one way or another, on the question of secrets. A few principles that recur everywhere in my workflows:

A secret isn't necessarily a simple string. In email-autoreply, ACCOUNTS_JSON contains the entire minified JSON of the multi-account config -- not just an API key, a complete data structure, injected as-is into a file at runtime:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

That avoids having to commit a config file, even encrypted, and it can be updated with one click in repo settings without touching the code.

GITHUB_TOKEN has precise limits, and that's intentional. The automatic token GitHub injects into each run is powerful, but sealed on certain points: by default, it can't trigger another workflow, and depending on the repo config it can be blocked by branch protection rules. That's exactly why create-pull-request.yml requires a separate PAT (AUTO_PR_TOKEN) -- a token from a real account (or a GitHub App), with explicit contents:write + pull-requests:write rights, distinct from the job's ephemeral token.

Permissions are scoped job by job, not globally. Every workflow I've listed here declares a minimal, commented permissions: block:

permissions:
  contents: read
  actions: read
  checks: write
  # Only allow writing checks, everything else is read-only

The default GITHUB_TOKEN historically has fairly broad rights on a public repo; explicitly restricting it to what the job actually needs limits the damage if a third-party action in the chain (uses: someone/action@version) turns out to be compromised.

The best secret is the one that doesn't exist. The OIDC pattern from typescript-virtual-container is the most complete version of this idea: instead of managing rotation, expiration, and leakage risk of an NPM_TOKEN, the workflow cryptographically proves its identity (this exact repo, this exact workflow) directly to the third-party service. Same logic available for AWS, Docker Hub, PyPI -- more and more registries and clouds support OIDC from GitHub Actions.


3 key points

  1. A git tag (orphan, force-pushed) can serve as a minimalist database or a precompiled build cache -- two distinct uses of the same mechanism.
  2. A free GitHub Actions runner can become a persistent SSH shell if you accept continuously syncing its filesystem to a git branch, with autosave via inotifywait and a single amended commit.
  3. The default GITHUB_TOKEN is intentionally limited -- creating cross-branch PRs or publishing without secrets requires either a dedicated PAT, or switching to OIDC trusted publishing.

5 manières détournées d'utiliser GitHub Actions (et ce que ça apprend sur les secrets)

Un runner CI transformé en VPS gratuit, un bot qui ouvre ses propres pull requests, un publish npm sans le moindre secret. Tour de mes repos pour lister les patterns GitHub Actions qui sortent du simple \"lint + test + deploy\".

5 manières détournées d'utiliser GitHub Actions

GitHub Actions, sur le papier, c'est fait pour du CI/CD classique : tu push, ça lint, ça teste, ça déploie. J'ai déjà écrit sur un cas particulier -- utiliser des tags git comme base de données pour un bot email (voir l'article dédié). Mais en fouillant dans mes propres repos, il y a assez de patterns différents pour que ça vaille un article à part, moins centré sur un seul projet, plus catalogue de techniques.

Cinq trucs, du plus classique au plus tordu.

1. Un tag git comme état persistant entre deux runs

Rapide rappel, le détail complet est dans l'article sur email-autoreply. GitHub Actions est stateless par design -- chaque run part d'une machine vierge. Le contournement : stocker une valeur (un ID, un timestamp, n'importe quel petit état) dans un tag git dédié, jamais dans une branche.

# lire l'état
git show refs/tags/lastid:data/lastId > data/lastId

# écrire l'état (branche orpheline, un seul commit, force-push du tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Le point clé : une branche orpheline pour ne jamais accumuler d'historique, et un tag forcé plutôt qu'une branche pour ne pas polluer la liste des branches du repo.

2. Un tag git comme cache de build précompilé

Même famille d'idée, autre usage : au lieu de stocker un état applicatif, on stocke un artefact de build. Le job build compile le code une fois (sur push vers master), puis pousse dist/ + node_modules/ dans un tag runtime. Le job cron, lui, checkout directement ce tag au lieu de faire tourner bun install && bun run build à chaque exécution :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# pas de install, pas de build -- le code est déjà prêt
- run: node dist/index.js --action

Ça change le run de ~20s à ~10s. Sur un cron qui tourne souvent, ça compte. actions/cache fait un travail similaire (mettre en cache des dépendances), mais un tag git est plus direct quand tu veux carrément figer un artefact versionné et le pointer explicitement -- pas juste accélérer un npm install.

3. Un seul check obligatoire qui agrège plusieurs jobs

Petit pattern qui n'a l'air de rien mais qui change la vie en configuration de branche protégée. Sur konosuba-rpg, la CI a trois jobs indépendants (typecheck, lint, tests) qui tournent en parallèle -- et un quatrième job, test-battery, qui ne fait rien d'autre que dépendre des trois premiers :

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Sans ce job de façade, configurer une branche protégée demanderait de cocher trois checks obligatoires séparés -- et de mettre à jour cette liste à chaque fois qu'un job est ajouté ou renommé. Avec test-battery, un seul nom à cocher dans les paramètres du repo, qui reste stable même si le détail interne change.

4. Transformer un runner gratuit en VPS temporaire

Celui-là est le plus tordu de tous, et clairement mon préféré : repo-to-vps détourne complètement l'usage prévu d'un runner GitHub Actions pour en faire une machine Linux accessible en SSH, gratuite, pendant jusqu'à 6 heures (la limite max d'un job).

Le principe : un job qui ne fait quasiment rien d'autre que lancer tmate (un fork de tmux qui expose une session terminal partageable par SSH ou navigateur) :

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Le vrai casse-tête, c'est que le filesystem d'un runner GitHub Actions est jetable -- dès que le job se termine, tout disparaît. Une session SSH qui dure des heures, ça sert à rien si tout ce que t'y fais s'évapore au prochain run. La solution : une branche git qui sert de snapshot live du filesystem, synchronisée en continu.

Le script start-tmate.sh fait, dans l'ordre :

  1. Restaure le filesystem depuis une branche dédiée filesystem au démarrage du job (git reset --hard dessus).
  2. Surveille les changements de fichiers avec inotifywait en continu, et commit + push immédiatement dès qu'un fichier bouge :
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1   # debounce
  done
}
  1. Chaque sauvegarde amende le commit précédent plutôt que d'en créer un nouveau (git commit --amend --no-edit), donc la branche filesystem reste toujours à un seul commit -- pas d'accumulation de milliers de snapshots.
  2. Une boucle while true relance tmate automatiquement si la session meurt, avec remain-on-exit on pour que le terminal reste joignable même après un exit.
  3. L'URL SSH générée par tmate est écrite dans un fichier host.conf, committé sur la branche filesystem -- donc récupérable via l'API GitHub (gh api .../contents/host.conf) sans jamais avoir eu accès aux logs du job en direct.
  4. Une routine periodic_save tourne toutes les 5 secondes en tâche de fond, au cas où inotifywait raterait un événement.

Résultat : un shell Linux complet, accessible depuis n'importe où, avec un filesystem qui persiste entre les sessions -- alors que l'infrastructure sous-jacente (un runner GitHub Actions) n'a absolument pas été conçue pour ça. La seule vraie limite, c'est le timeout de 6h par job -- après quoi il faut relancer le workflow.

5. Un bot qui ouvre ses propres pull requests

Sur konosuba-rpg, un push sur la branche dev déclenche un job qui vérifie s'il existe déjà une pull request ouverte vers main -- et en crée une automatiquement sinon, via actions/github-script et l'API REST GitHub :

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return; // rien à proposer

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return; // déjà une PR ouverte

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Le détail qui compte ici, c'est le token utilisé. Ce workflow ne se sert pas du GITHUB_TOKEN automatique -- il exige un secret AUTO_PR_TOKEN séparé, et refuse de continuer s'il est absent :

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Publier sur npm sans aucun secret

Le plus discret des cinq, mais probablement le plus important pour l'avenir : le workflow publish.yml de typescript-virtual-container ne contient aucun secret npm. Pas de NPM_TOKEN, pas de NODE_AUTH_TOKEN. Juste ça :

permissions:
  id-token: write  # Required for OIDC
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish marche quand même, parce que le registre npm supporte désormais le trusted publishing via OIDC : le workflow prouve son identité directement au registre (repo + workflow exacts, configurés côté npmjs.org), sans qu'aucun token statique ne transite ni ne soit stocké nulle part. Zéro secret à faire fuiter, zéro token à faire tourner tous les six mois.


Les secrets GitHub, en profondeur

Ces cinq patterns touchent tous, d'une manière ou d'une autre, à la question des secrets. Quelques principes qui reviennent partout dans mes workflows :

Un secret n'est pas forcément une chaîne simple. Dans email-autoreply, ACCOUNTS_JSON contient le JSON minifié entier de la config multi-comptes -- pas juste une clé API, une structure de données complète, injectée telle quelle dans un fichier au runtime :

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Ça évite d'avoir à committer un fichier de config, même chiffré, et ça se met à jour en un clic dans les settings du repo sans toucher au code.

GITHUB_TOKEN a des limites précises, et c'est voulu. Le token automatique que GitHub injecte à chaque run est puissant, mais scellé sur certains points : par défaut, il ne peut pas déclencher d'autre workflow, et selon la configuration du repo il peut être bloqué par des règles de protection de branche. C'est exactement pour ça que create-pull-request.yml exige un PAT (AUTO_PR_TOKEN) séparé -- un token qui vient d'un vrai compte (ou d'une GitHub App), avec des droits explicites contents:write + pull-requests:write, distinct du token éphémère du job.

Les permissions se scopent job par job, pas globalement. Chaque workflow que j'ai listé ici déclare un bloc permissions: minimal et commenté :

permissions:
  contents: read
  actions: read
  checks: write
  # Only allow writing checks, everything else is read-only

Le GITHUB_TOKEN par défaut a historiquement des droits assez larges sur un repo public ; le restreindre explicitement à ce dont le job a réellement besoin limite les dégâts si une action tierce dans la chaîne (uses: quelqu-un/action@version) se révèle compromise.

Le meilleur secret est celui qui n'existe pas. Le pattern OIDC de typescript-virtual-container est la version la plus aboutie de cette idée : au lieu de gérer la rotation, l'expiration et le risque de fuite d'un NPM_TOKEN, le workflow prouve cryptographiquement son identité (ce repo précis, ce workflow précis) directement au service tiers. Même logique disponible pour AWS, Docker Hub, PyPI -- de plus en plus de registres et clouds supportent OIDC depuis GitHub Actions.


3 points clés

  1. Un tag git (orphelin, force-pushé) peut servir de base de données minimaliste ou de cache de build précompilé -- deux usages distincts du même mécanisme.
  2. Un runner GitHub Actions gratuit peut devenir un shell SSH persistant si on accepte de synchroniser son filesystem en continu vers une branche git, avec autosave par inotifywait et un seul commit amendé.
  3. Le GITHUB_TOKEN par défaut est volontairement limité -- créer des PR inter-branches ou publier sans secret demande soit un PAT dédié, soit un passage à l'OIDC trusted publishing.

5 个巧妙利用 GitHub Actions 的方法(以及它们对 secret 的启示)

CI runner 变身免费 VPS,自动给自己开 PR 的机器人,零 secret 的 npm 发布。遍历我的仓库,梳理那些超越 lint+test+deploy 的 GitHub Actions 模式。

5 个巧妙利用 GitHub Actions 的方法

理论上,GitHub Actions 就是用来做传统 CI/CD 的:你 push,它 lint、test、deploy。我之前写过一篇专门的文章,关于用 git tag 给邮件机器人当数据库。但翻翻我自己的仓库,不同的模式足够多,值得单独写一篇----不那么聚焦一个项目,更像一个技术目录。

五件事,从最经典到最扭曲。

1. 用 git tag 在两次运行之间保存状态

快速回顾,详情见那篇关于 email-autoreply 的文章。GitHub Actions 本质上是无状态的----每次运行都从一台干净的机器开始。变通方案:把一个值(一个 ID、一个时间戳、任意小状态)存到一个专门的 git tag 里,而不是分支里。

# 读取状态
git show refs/tags/lastid:data/lastId > data/lastId

# 写入状态(孤儿分支、单次 commit、强制 push tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

关键点:孤儿分支永远不会累积历史,强制推送 tag 而不是分支,不会污染仓库的分支列表。

2. 用 git tag 做预编译的构建缓存

同类思路,不同用途:不存应用状态,而是存一个构建产物。build job 编译一次代码(push 到 master 时),然后把 dist/ + node_modules/ 推到一个 runtime tag 里。cron job 直接 checkout 这个 tag,而不是每次都跑一遍 bun install && bun run build:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# 无需 install,无需 build----代码已经就绪
- run: node dist/index.js --action

这让运行时间从大约 20 秒降到 10 秒。对于经常跑的 cron 来说,这很关键。actions/cache 做类似的事(缓存依赖),但当你想要彻底冻结一个带版本的产物并显式指向它----而不只是加速 npm install 时,git tag 更直接。

3. 用一个必需检查汇总多个 job

一个看起来不起眼但彻底改变分支保护配置的小模式。在 konosuba-rpg 中,CI 有三个独立 job(typecheck、lint、tests)并行运行----第四个 job test-battery 什么也不做,只依赖前三个:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

没有这个门面 job,配置受保护分支就需要勾选三个独立的必过检查----每次添加或重命名 job 时都得更新那个列表。有了 test-battery,仓库设置里只需要勾一个名字,内部细节变了也不影响。

4. 把免费 runner 变成临时 VPS

这是五个里最扭曲的,也绝对是我最喜欢的:repo-to-vps 完全劫持了 GitHub Actions runner 的原本用途,把它变成一台可以通过 SSH 访问的 Linux 机器,免费,最长 6 小时(一个 job 的最大时长)。

原理:一个几乎只做一件事的 job----启动 tmate(tmux 的分支,通过 SSH 或浏览器暴露可共享的终端会话):

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

真正棘手的是:GitHub Actions runner 的文件系统是一次性的----job 一结束,所有东西都消失。一个持续几小时的 SSH 会话,如果你做的所有事在下一次运行都蒸发掉,那就毫无意义。解决方案:一个 git 分支充当文件系统的实时快照,持续同步。

start-tmate.sh 脚本依次做这些事:

  1. 在 job 启动时从一个专门的 filesystem 分支恢复文件系统(git reset --hard 到它上面)。
  2. 用 inotifywait 持续监听文件变化,任何文件一动就立即 commit + push:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. 每次保存修改上一个 commit 而不是创建新的(git commit --amend --no-edit),所以 filesystem 分支永远只有一个 commit----不会累积成千上万的快照。
  2. while true 循环在会话断开时自动重启 tmate,remain-on-exit on 确保即使 exit 后终端仍然可以连接。
  3. tmate 生成的 SSH URL 被写入 host.conf 文件,commit 到 filesystem 分支----无需实时访问 job 日志,通过 GitHub API(gh api .../contents/host.conf)就能获取。
  4. periodic_save 例程每 5 秒在后台运行一次,以防 inotifywait 漏掉事件。

结果:一个完整的 Linux shell,可以从任何地方访问,文件系统在会话间保持持久----尽管底层基础设施(一个 GitHub Actions runner)绝对不是为了这个目的设计的。唯一真正的限制是每个 job 6 小时的超时----之后需要重新启动 workflow。

5. 自动给自己开 PR 的机器人

在 konosuba-rpg 中,push 到 dev 分支会触发一个 job,检查是否已经有指向 main 的开放 PR----如果没有,就用 actions/github-script 和 GitHub REST API 自动创建一个:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

这里关键的细节是使用的 token。这个 workflow 不用自动的 GITHUB_TOKEN----它要求一个单独的 AUTO_PR_TOKEN secret,缺失就拒绝继续:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. 零 secret 发布到 npm

五个里面最安静的,但可能对未来最重要:typescript-virtual-container 的 publish.yml workflow 没有任何 npm secret。没有 NPM_TOKEN,没有 NODE_AUTH_TOKEN。就这些:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish 照样能跑,因为 npm registry 现在支持通过 OIDC 的 trusted publishing:workflow 直接向 registry 证明自己的身份(精确的 repo + 精确的 workflow,在 npmjs.org 侧配置),没有任何静态 token 传输或存储在任何地方。零 secret 可泄露,零 token 需要每六个月轮换。


GitHub secret 深度解析

这五个模式都以某种方式涉及 secret 问题。我的 workflow 中反复出现的几个原则:

secret 不一定是简单字符串。 在 email-autoreply 中,ACCOUNTS_JSON 包含整个多账户配置的压缩 JSON----不只是一个 API key,而是一个完整的数据结构,在运行时原样注入文件:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

这避免了提交配置文件(即使是加密的),并且可以在仓库设置中一键更新而不碰代码。

GITHUB_TOKEN 有精确的限制,这是有意为之。 GitHub 每次运行注入的自动 token 很强大,但在某些点上是封死的:默认情况下它不能触发其他 workflow,并且根据仓库配置可能被分支保护规则阻止。这正是 create-pull-request.yml 要求单独 PAT(AUTO_PR_TOKEN)的原因----来自真实账户(或 GitHub App)的 token,具有明确的 contents:write + pull-requests:write 权限,与 job 的临时 token 分开。

权限按 job 而非全局作用域。 我在这里列出的每个 workflow 都声明了一个最小的、带注释的 permissions: 块:

permissions:
  contents: read
  actions: read
  checks: write

默认的 GITHUB_TOKEN 历史上对公共仓库有相当广泛的权限;明确限制为 job 真正需要的权限,可以限制链中的第三方 action 被攻破时的损害。

最好的 secret 是不存在的 secret。 typescript-virtual-container 的 OIDC 模式是这一理念最完整的版本:不去管理 NPM_TOKEN 的轮换、过期和泄露风险,workflow 以密码学方式直接向第三方服务证明自己的身份(这个精确的仓库,这个精确的 workflow)。同样的逻辑适用于 AWS、Docker Hub、PyPI----越来越多的 registry 和云服务支持从 GitHub Actions 进行 OIDC。


3 个关键点

  1. 一个 git tag(孤儿,强制推送)可以充当极简数据库或预编译的构建缓存----同一机制的两种不同用途。
  2. 一个免费的 GitHub Actions runner 可以变成持久化的 SSH shell,只要接受将其文件系统通过 inotifywait 自动保存并以单个 amended commit 持续同步到 git 分支。
  3. 默认的 GITHUB_TOKEN 是有意受限的----创建跨分支 PR 或零 secret 发布需要专用 PAT,或切换到 OIDC trusted publishing。

GitHub Actionsを巧妙に使う5つの方法(そしてシークレットについて学ぶこと)

CIランナーを無料VPSに変身、自分でPRを開くボット、シークレットなしのnpmパブリッシュ。「lint + test + deploy」を超えたGitHub Actionsパターンのカタログ。

GitHub Actionsを巧妙に使う5つの方法

GitHub Actionsは本来、古典的なCI/CDのためのものだ。プッシュすればリントし、テストし、デプロイする。特殊なケースについてはすでに書いた -- gitタグをメールボットのデータベースとして使う方法(専用記事参照)。でも自分のリポジトリを掘り返すと、一つのプロジェクトに絞らずカタログ的にまとめる価値があるくらいパターンが揃ってた。

五つ。一番普通のから一番ひねくれたやつまで。

1. gitタグをラン間の永続状態として使う

簡単に復習すると、詳細はemail-autoreplyの記事にある。GitHub Actionsは設計上ステートレスだ -- 毎回まっさらなマシンから始まる。回避策: 値(ID、タイムスタンプなど小さな状態)を専用のgitタグに保存する。ブランチじゃなくて。

# 状態を読む
git show refs/tags/lastid:data/lastId > data/lastId

# 状態を書く(孤立ブランチ、単一コミット、タグをforce-push)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

キモ: 孤立ブランチで履歴を一切蓄積せず、ブランチではなく強制タグでリポジトリのブランチ一覧を汚さない。

2. gitタグをプリコンパイル済みビルドキャッシュとして使う

同じ発想の別用途: アプリの状態の代わりにビルドアーティファクトを保存する。buildジョブがコードを一度コンパイルし(masterへのプッシュ時)、dist/ + node_modules/をruntimeタグにプッシュする。cronジョブは毎回bun install && bun run buildを回す代わりにこのタグを直接チェックアウトする:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# installもbuildもなし -- コードは準備済み
- run: node dist/index.js --action

これで実行時間が約20秒から約10秒に変わる。頻繁に回るcronでは意味がある。actions/cacheも似た仕事をするが(依存関係のキャッシュ)、gitタグはバージョン付きアーティファクトを丸ごと凍結して明示的に指したい時により直接的だ -- 単にnpm installを速くするだけじゃない。

3. 複数ジョブを束ねる単一の必須チェック

地味だけどブランチ保護設定を根本から変えるパターン。konosuba-rpgでは、CIに3つの独立ジョブ(typecheck、lint、tests)が並列で走り -- 4つ目のジョブtest-batteryは最初の3つに依存するだけで何もしない:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

このファサードジョブなしだと、保護ブランチの設定で3つの必須チェックを別々にチェックし、ジョブが追加されたり名前が変わったりするたびにそのリストを更新しなきゃいけない。test-batteryがあれば、リポジトリ設定で名前一つチェックするだけで、内部が変わっても安定してる。

4. 無料ランナーを一時VPSに変える

これが一番ひねくれていて、明らかに俺のお気に入り: repo-to-vpsはGitHub Actionsランナーの本来の使い方を完全に乗っ取って、SSHでアクセス可能なLinuxマシンにする。無料。最大6時間(ジョブの最大時間)。

原理: tmateを起動する以外ほとんど何もしないジョブ:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

本当の難題は、GitHub Actionsランナーのファイルシステムが使い捨てだってことだ -- ジョブが終わった瞬間に全部消える。何時間も続くSSHセッションも、やったことが次の実行で蒸発したら無意味だ。解決策: ファイルシステムのライブスナップショットとして機能するgitブランチ。継続的に同期。

start-tmate.shスクリプトは順に:

  1. ジョブ開始時に専用のfilesystemブランチからファイルシステムを復元する(git reset --hard)。
  2. inotifywaitでファイル変更を継続的に監視し、ファイルが動くたびに即コミット+プッシュ:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. 保存のたびに新しいコミットではなく前のコミットを修正する(git commit --amend --no-edit)、だからfilesystemブランチは常に単一コミット -- 何千ものスナップショットが溜まらない。
  2. while trueループでセッションが死んだらtmateを自動再起動、remain-on-exit onでexit後もターミナルに接続可能。
  3. tmateが生成したSSH URLをhost.confファイルに書き込み、filesystemブランチにコミット -- ジョブのログにライブアクセスできなくてもGitHub API(gh api .../contents/host.conf)で取得可能。
  4. periodic_saveルーチンが5秒ごとにバックグラウンドで回り、inotifywaitがイベントを見逃した場合に備える。

結果: どこからでもアクセス可能な完全なLinuxシェル。セッション間でファイルシステムが持続する -- 基盤インフラ(GitHub Actionsランナー)は絶対にそんな用途に設計されてないのに。唯一の本当の制限はジョブあたり6時間のタイムアウト -- それを過ぎたらワークフローを再起動する必要がある。

5. 自分でPRを開くボット

konosuba-rpgでは、devブランチへのプッシュがmainへのオープンなPRが既にあるかチェックするジョブをトリガーする -- なければactions/github-scriptとGitHub REST APIで自動生成:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

ここで重要なのは使われるトークンだ。このワークフローは自動のGITHUB_TOKENを使わない -- 別途AUTO_PR_TOKENシークレットを要求し、なければ続行を拒否する:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. シークレットなしでnpmにパブリッシュ

五つの中で一番静かだけど、未来にとって一番重要かもしれない: typescript-virtual-containerのpublish.ymlワークフローにはnpmシークレットが一切ない。NPM_TOKENもNODE_AUTH_TOKENもなし。これだけ:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publishが動作するのは、npmレジストリがOIDC経由のtrusted publishingをサポートするようになったからだ: ワークフローがレジストリに直接自分の身元を証明し(npmjs.org側で設定された正確なリポジトリ+正確なワークフロー)、静的なトークンがどこにも保存も転送もされない。漏洩するシークレットも、半年ごとにローテートするトークンもゼロ。


GitHubシークレット、深掘り

この5つのパターンは全部、何らかの形でシークレットの問題に触れている。俺のワークフロー全体で繰り返し出てくる原則:

シークレットは必ずしも単純な文字列じゃない。 email-autoreplyでは、ACCOUNTS_JSONがマルチアカウント設定のミニファイされたJSON全体を含んでいる -- APIキーだけじゃなく、完全なデータ構造をランタイムにそのままファイルに注入:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

設定ファイルをコミットする必要がなく(暗号化されていても)、リポジトリ設定でワンクリックでコードに触れずに更新できる。

GITHUB_TOKENには正確な制限があり、それは意図的なものだ。 GitHubが各実行に注入する自動トークンは強力だが、特定の点で封印されている: デフォルトでは別のワークフローをトリガーできず、リポジトリ設定によってはブランチ保護ルールにブロックされる。だからこそcreate-pull-request.ymlが別途PAT(AUTO_PR_TOKEN)を要求する -- 実アカウント(またはGitHub App)のトークン、明示的なcontents:write + pull-requests:write権限付き、ジョブの一時トークンとは別。

権限はグローバルではなくジョブごとにスコープされる。 ここに挙げた全ワークフローが最小限のコメント付きpermissions:ブロックを宣言している:

permissions:
  contents: read
  actions: read
  checks: write

デフォルトのGITHUB_TOKENは歴史的に公開リポジトリに対してかなり広い権限を持つ。ジョブが実際に必要とするものだけに明示的に制限すれば、チェーン内のサードパーティアクションが侵害された場合の被害を抑えられる。

最高のシークレットは存在しないシークレットだ。 typescript-virtual-containerのOIDCパターンはこの考え方の最も完成された形だ: NPM_TOKENのローテーション、期限切れ、漏洩リスクを管理する代わりに、ワークフローが暗号的に自分の身元(この正確なリポジトリ、この正確なワークフロー)をサードパーティサービスに直接証明する。AWS、Docker Hub、PyPIでも同じロジックが使える -- どんどん多くのレジストリとクラウドがGitHub ActionsからのOIDCをサポートしている。


3つのポイント

  1. gitタグ(孤立、force-push)はミニマリストなデータベースやプリコンパイル済みビルドキャッシュとして機能する -- 同じ仕組みの2つの異なる使い方。
  2. 無料のGitHub Actionsランナーは、inotifywaitで自動保存し単一のamendコミットでファイルシステムをgitブランチに継続同期することを受け入れれば、永続的なSSHシェルになれる。
  3. デフォルトのGITHUB_TOKENは意図的に制限されている -- ブランチ間PRの作成やシークレットなしのパブリッシュには専用PATかOIDC trusted publishingへの切り替えが必要。

GitHub Actions를 기발하게 활용하는 5가지 방법 (그리고 시크릿에 대해 배우는 것)

CI 러너를 무료 VPS로 변신, 스스로 PR을 여는 봇, 시크릿 없는 npm 배포. 단순한 lint+test+deploy를 넘어선 GitHub Actions 패턴 카탈로그.

GitHub Actions를 기발하게 활용하는 5가지 방법

GitHub Actions는 원래 고전적인 CI/CD를 위한 거다: 푸시하면 린트하고, 테스트하고, 배포한다. 특수 케이스에 대해 이미 글을 썼다 -- git 태그를 이메일 봇 데이터베이스로 쓰는 방법(전용 글 참고). 하지만 내 레포를 뒤져보니 하나의 프로젝트에 집중하기보다 카탈로그처럼 정리할 만한 패턴들이 꽤 많았다.

다섯 가지. 가장 평범한 것부터 가장 기발한 것까지.

1. git 태그를 런 간 영속 상태로 쓰기

간단히 요약하자면, 자세한 건 email-autoreply 글에 있다. GitHub Actions는 설계상 stateless다 -- 매번 빈 머신에서 시작한다. 우회 방법: 값(ID, 타임스탬프 등 작은 상태)을 전용 git 태그에 저장하는 거다. 브랜치가 아니라.

# 상태 읽기
git show refs/tags/lastid:data/lastId > data/lastId

# 상태 쓰기 (고아 브랜치, 단일 커밋, 태그 force-push)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

핵심: 고아 브랜치로 히스토리가 쌓이지 않게 하고, 브랜치 대신 강제 태그로 레포의 브랜치 목록을 더럽히지 않는다.

2. git 태그를 미리 컴파일된 빌드 캐시로 쓰기

같은 아이디어 계열, 다른 용도: 앱 상태 대신 빌드 아티팩트를 저장한다. build 잡이 코드를 한 번 컴파일하고 (master 푸시 시), dist/ + node_modules/를 runtime 태그에 푸시한다. cron 잡은 매번 bun install && bun run build를 돌리는 대신 이 태그를 바로 체크아웃한다:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# install도 build도 없이 -- 코드는 이미 준비됨
- run: node dist/index.js --action

런 타임이 ~20초에서 ~10초로 줄어든다. 자주 도는 크론에선 의미 있다. actions/cache도 비슷한 일을 하지만(의존성 캐싱), git 태그는 버전이 찍힌 아티팩트를 통째로 얼리고 명시적으로 가리킬 때 더 직관적이다 -- 단순히 npm install을 빠르게 하는 것 이상.

3. 여러 잡을 묶는 단일 필수 체크

별거 아닌 것 같지만 브랜치 보호 설정을 확 바꿔주는 패턴. konosuba-rpg에서 CI는 세 개의 독립 잡(typecheck, lint, tests)이 병렬로 돌고 -- 네 번째 잡 test-battery는 앞의 세 개에 의존만 할 뿐 아무것도 안 한다:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

이 외관상 잡이 없으면 보호 브랜치 설정할 때 체크를 세 개 따로 찍고, 잡이 추가되거나 이름이 바뀔 때마다 그 목록을 갱신해야 한다. test-battery가 있으면 레포 설정에서 이름 하나만 체크하면 되고, 내부가 바뀌어도 그대로 유지된다.

4. 무료 러너를 임시 VPS로 탈바꿈시키기

제일 기발하고 분명 제일 마음에 드는 거: repo-to-vps는 GitHub Actions 러너의 원래 용도를 완전히 비틀어서 SSH로 접속 가능한 Linux 머신으로 만든다. 무료. 최대 6시간(잡 최대 시간).

원리: tmate를 실행하는 것 말고는 거의 아무것도 안 하는 잡.

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

진짜 난제는 GitHub Actions 러너의 파일시스템이 일회용이라는 점이다 -- 잡이 끝나는 순간 전부 사라진다. 몇 시간짜리 SSH 세션도 다음 실행에서 증발하면 무의미하다. 해결책: 파일시스템의 라이브 스냅샷 역할을 하는 git 브랜치, 지속적으로 동기화.

start-tmate.sh 스크립트는 순서대로:

  1. 잡 시작 시 전용 filesystem 브랜치에서 파일시스템을 복원한다 (git reset --hard).
  2. inotifywait로 파일 변경을 지속적으로 감시하고, 파일이 바뀌는 즉시 커밋 + 푸시한다:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. 저장할 때마다 새 커밋 대신 이전 커밋을 수정한다 (git commit --amend --no-edit), 그래서 filesystem 브랜치는 항상 단일 커밋 상태 -- 수천 개의 스냅샷이 쌓이지 않는다.
  2. while true 루프로 세션이 죽으면 tmate를 자동 재시작, remain-on-exit on으로 exit 후에도 터미널 접속 가능.
  3. tmate가 생성한 SSH URL을 host.conf 파일에 쓰고 filesystem 브랜치에 커밋 -- 잡 로그를 라이브로 볼 수 없어도 GitHub API (gh api .../contents/host.conf)로 가져올 수 있다.
  4. periodic_save 루틴이 5초마다 백그라운드에서 돌면서 inotifywait가 이벤트를 놓친 경우에 대비한다.

결과: 어디서든 접속 가능한 완전한 Linux 셸. 세션 간에 파일시스템이 유지된다 -- 기반 인프라(GitHub Actions 러너)는 전혀 그런 용도로 설계되지 않았는데도. 유일한 진짜 제한은 잡당 6시간 타임아웃 -- 그 이후엔 워크플로우를 다시 시작해야 한다.

5. 스스로 PR을 여는 봇

konosuba-rpg에서 dev 브랜치 푸시가 main으로 열린 PR이 이미 있는지 확인하는 잡을 트리거한다 -- 없으면 actions/github-script와 GitHub REST API로 자동 생성:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

여기서 중요한 건 사용된 토큰이다. 이 워크플로우는 자동 GITHUB_TOKEN을 안 쓴다 -- 별도 AUTO_PR_TOKEN 시크릿을 요구하고, 없으면 실행을 거부한다:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. 시크릿 없이 npm에 배포하기

다섯 가지 중 가장 조용하지만, 미래를 위해 가장 중요한 것: typescript-virtual-container의 publish.yml 워크플로우는 npm 시크릿이 전혀 없다. NPM_TOKEN도, NODE_AUTH_TOKEN도 없다. 그냥 이거:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish가 작동하는 건, npm 레지스트리가 이제 OIDC를 통한 trusted publishing을 지원하기 때문이다: 워크플로우가 바로 레지스트리에 자신의 신원을 증명하고(npmjs.org 쪽에 설정된 정확한 레포 + 워크플로우), 정적 토큰이 어디에도 저장되거나 전송되지 않는다. 유출될 시크릿도, 6개월마다 교체할 토큰도 없다.


GitHub 시크릿, 깊이 파보기

이 다섯 패턴은 전부 어떤 식으로든 시크릿 문제에 닿아 있다. 내 워크플로우 전체에 걸쳐 반복되는 몇 가지 원칙:

시크릿이 꼭 단순한 문자열일 필요는 없다. email-autoreply에서 ACCOUNTS_JSON은 멀티계정 설정의 축소된 JSON 전체를 담고 있다 -- 단순한 API 키가 아니라 완전한 데이터 구조를 런타임에 파일로 그대로 주입한다:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

설정 파일을 커밋할 필요가 없고(암호화된 것도), 레포 설정에서 한 번 클릭으로 코드 수정 없이 업데이트 가능하다.

GITHUB_TOKEN은 정확한 제한이 있고, 그건 의도된 거다. GitHub가 각 실행에 주입하는 자동 토큰은 강력하지만 특정 지점에서 봉인되어 있다: 기본적으로 다른 워크플로우를 트리거할 수 없고, 레포 설정에 따라 브랜치 보호 규칙에 막힐 수 있다. 바로 그래서 create-pull-request.yml이 별도 PAT(AUTO_PR_TOKEN)을 요구하는 거다 -- 진짜 계정(또는 GitHub App)의 토큰, 명시적 contents:write + pull-requests:write 권한, 잡의 임시 토큰과 분리된.

권한은 전역이 아니라 잡별로 스코핑된다. 여기 나열한 모든 워크플로우는 최소한의 주석 달린 permissions: 블록을 선언한다:

permissions:
  contents: read
  actions: read
  checks: write

기본 GITHUB_TOKEN은 역사적으로 공개 레포에 꽤 넓은 권한을 가진다; 잡이 실제로 필요로 하는 것만으로 명시적으로 제한하면 체인 속 서드파티 액션이 손상되었을 때 피해를 줄일 수 있다.

최고의 시크릿은 존재하지 않는 시크릿이다. typescript-virtual-container의 OIDC 패턴은 이 아이디어의 가장 완성된 버전이다: NPM_TOKEN의 교체, 만료, 유출 위험을 관리하는 대신, 워크플로우가 암호학적으로 자신의 신원(이 정확한 레포, 이 정확한 워크플로우)을 서드파티 서비스에 직접 증명한다. AWS, Docker Hub, PyPI에도 동일한 로직 사용 가능 -- 점점 더 많은 레지스트리와 클라우드가 GitHub Actions의 OIDC를 지원하고 있다.


핵심 3가지

  1. git 태그(고아, force-push)는 미니멀리스트 데이터베이스나 미리 컴파일된 빌드 캐시 역할을 할 수 있다 -- 같은 메커니즘의 두 가지 용도.
  2. 무료 GitHub Actions 러너는 inotifywait로 자동 저장하고 단일 수정 커밋으로 git 브랜치에 파일시스템을 지속 동기화하면 영속 SSH 셸이 될 수 있다.
  3. 기본 GITHUB_TOKEN은 의도적으로 제한되어 있다 -- 브랜치 간 PR을 만들거나 시크릿 없이 배포하려면 전용 PAT나 OIDC trusted publishing으로 전환이 필요하다.

GitHub Actions'ı dahice kullanmanın 5 yolu (ve secret'lar hakkında öğrettikleri)

CI runner'ı bedava VPS'e dönüştürmek, kendi PR'larını açan bot, sıfır secret'la npm publish. \"lint + test + deploy\"un ötesine geçen GitHub Actions desenlerini kataloglamak için repolarımda bir tur.

GitHub Actions'ı dahice kullanmanın 5 yolu

Kâğıt üstünde, GitHub Actions klasik CI/CD için yapılmıştır: push edersin, lint'ler, test eder, deploy eder. Özel bir durum hakkında zaten yazdım -- git tag'leri bir email botu için veritabanı olarak kullanmak (özel makaleye bakın). Ama kendi repolarımı kazıyınca, tek bir projeye odaklanmaktan çok bir teknik kataloğu gibi ayrı bir makaleyi hak edecek kadar farklı desen var.

Beş şey, en klasikten en çarpık olana.

1. Git tag'i, iki çalıştırma arasında kalıcı durum olarak kullanmak

Hızlı özet, tam detaylar email-autoreply makalesinde. GitHub Actions doğası gereği durumsuzdur -- her çalıştırma boş bir makineden başlar. Çözüm: bir değeri (ID, zaman damgası, herhangi küçük bir durumu) özel bir git tag'inde saklamak, asla bir branch'te değil.

# durumu oku
git show refs/tags/lastid:data/lastId > data/lastId

# durumu yaz (öksüz branch, tek commit, tag'i force-push et)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Kilit nokta: tarihçe birikmesin diye öksüz branch, ve repo'nun branch listesini kirletmemek için branch yerine zorlanmış tag.

2. Git tag'i önceden derlenmiş derleme önbelleği olarak kullanmak

Aynı fikir ailesi, farklı kullanım: uygulama durumu yerine bir derleme artefaktı saklamak. build işi kodu bir kez derler (master'a push'ta), sonra dist/ + node_modules/'ü bir runtime tag'ine push'lar. cron işi her seferinde bun install && bun run build çalıştırmak yerine doğrudan bu tag'i checkout eder:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# install yok, build yok -- kod hazır
- run: node dist/index.js --action

Bu, çalıştırma süresini ~20 saniyeden ~10 saniyeye düşürür. Sık çalışan bir cron'da bunun önemi var. actions/cache benzer bir iş yapar (bağımlılıkları önbelleğe alır), ama bir git tag'i, sürümlenmiş bir artefaktı tamamen dondurup açıkça işaret etmek istediğinizde daha doğrudandır -- sadece npm install'ı hızlandırmak değil.

3. Birden çok işi birleştiren tek bir zorunlu kontrol

Ufak, önemsiz görünen ama branch koruma yapılandırmasında her şeyi değiştiren bir desen. konosuba-rpg'de, CI'nin paralel çalışan üç bağımsız işi var (typecheck, lint, tests) -- ve dördüncü bir iş, test-battery, ilk üçüne bağımlı olmaktan başka hiçbir şey yapmaz:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Bu cephe iş olmadan, korumalı bir branch yapılandırmak üç ayrı zorunlu kontrolü işaretlemeyi -- ve her iş eklendiğinde ya da yeniden adlandırıldığında bu listeyi güncellemeyi gerektirirdi. test-battery ile, repo ayarlarında işaretlenecek tek bir isim, iç detaylar değişse bile sabit kalır.

4. Bedava bir runner'ı geçici bir VPS'e dönüştürmek

Bu, hepsinin en çarpığı ve açık ara favorim: repo-to-vps, bir GitHub Actions runner'ının amaçlanan kullanımını tamamen gasp ederek onu SSH ile erişilebilen bir Linux makinesine dönüştürür. Bedava. 6 saate kadar (bir işin maksimum süresi).

Prensip: tmate'i başlatmaktan başka neredeyse hiçbir şey yapmayan bir iş:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Asıl baş belası, GitHub Actions runner'ının dosya sisteminin kullan-at olmasıdır -- iş biter bitmez her şey kaybolur. Saatlerce süren bir SSH oturumu, yaptığınız her şey bir sonraki çalıştırmada buharlaşıyorsa işe yaramaz. Çözüm: dosya sisteminin canlı bir anlık görüntüsü olarak hizmet veren, sürekli senkronize edilen bir git branch'i.

start-tmate.sh betiği, sırayla şunları yapar:

  1. İş başlangıcında özel bir filesystem branch'inden dosya sistemini geri yükler (git reset --hard).
  2. inotifywait ile dosya değişikliklerini sürekli izler ve bir dosya hareket ettiğinde hemen commit + push yapar:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Her kayıt yeni bir commit oluşturmak yerine öncekini değiştirir (git commit --amend --no-edit), böylece filesystem branch'i her zaman tek bir commit'te kalır -- binlerce anlık görüntü birikimi olmaz.
  2. Bir while true döngüsü, oturum ölürse tmate'i otomatik olarak yeniden başlatır, remain-on-exit on ile terminal exit sonrasında bile erişilebilir kalır.
  3. tmate tarafından üretilen SSH URL'si bir host.conf dosyasına yazılır, filesystem branch'ine commit'lenir -- işin log'larına canlı erişim olmadan GitHub API (gh api .../contents/host.conf) üzerinden alınabilir.
  4. Bir periodic_save rutini, inotifywait bir olayı kaçırırsa diye her 5 saniyede bir arka planda çalışır.

Sonuç: her yerden erişilebilen tam bir Linux kabuğu, oturumlar arasında kalıcı bir dosya sistemiyle -- oysa alttaki altyapı (bir GitHub Actions runner'ı) kesinlikle bunun için tasarlanmamıştı. Tek gerçek sınır, iş başına 6 saatlik zaman aşımı -- sonrasında iş akışını yeniden başlatmak gerekir.

5. Kendi PR'larını açan bir bot

konosuba-rpg'de, dev branch'ine yapılan bir push, main'e açık bir PR olup olmadığını kontrol eden bir işi tetikler -- yoksa actions/github-script ve GitHub REST API ile otomatik olarak oluşturur:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Burada önemli olan detay, kullanılan token'dır. Bu iş akışı otomatik GITHUB_TOKEN'ı kullanmaz -- ayrı bir AUTO_PR_TOKEN secret'ı gerektirir ve yoksa devam etmeyi reddeder:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Sıfır secret ile npm'e yayınlamak

Beşlinin en sessizi, ama muhtemelen gelecek için en önemlisi: typescript-virtual-container'ın publish.yml iş akışı hiçbir npm secret'ı içermez. NPM_TOKEN yok, NODE_AUTH_TOKEN yok. Sadece bu:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish yine de çalışır, çünkü npm kayıt defteri artık OIDC üzerinden trusted publishing'i destekliyor: iş akışı kimliğini doğrudan kayıt defterine kanıtlar (tam repo + tam iş akışı, npmjs.org tarafında yapılandırılmış), hiçbir statik token hiçbir yerde iletilmez veya saklanmaz. Sızacak sıfır secret, her altı ayda bir döndürülecek sıfır token.


GitHub secret'ları, derinlemesine

Bu beş desenin hepsi, şu ya da bu şekilde, secret meselesine dokunuyor. İş akışlarımın her yerinde tekrar eden birkaç ilke:

Bir secret mutlaka basit bir string değildir. email-autoreply'de, ACCOUNTS_JSON çok hesaplı yapılandırmanın tüm minimize edilmiş JSON'unu içerir -- sadece bir API anahtarı değil, tam bir veri yapısı, çalışma zamanında olduğu gibi bir dosyaya enjekte edilir:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Bu, şifrelenmiş bile olsa bir yapılandırma dosyasını commit etmek zorunda kalmayı önler ve koda dokunmadan repo ayarlarında tek tıklamayla güncellenebilir.

GITHUB_TOKEN'ın kesin sınırları vardır ve bu kasıtlıdır. GitHub'ın her çalıştırmaya enjekte ettiği otomatik token güçlüdür, ancak belirli noktalarda mühürlenmiştir: varsayılan olarak başka bir iş akışını tetikleyemez ve repo yapılandırmasına bağlı olarak branch koruma kuralları tarafından engellenebilir. create-pull-request.yml'in ayrı bir PAT (AUTO_PR_TOKEN) gerektirmesinin nedeni tam olarak budur -- gerçek bir hesaptan (veya bir GitHub App'ten) token, açık contents:write + pull-requests:write haklarıyla, işin geçici token'ından ayrı.

İzinler global değil, iş bazında kapsamlandırılır. Burada listelediğim her iş akışı, minimal ve yorumlanmış bir permissions: bloğu bildirir:

permissions:
  contents: read
  actions: read
  checks: write

Varsayılan GITHUB_TOKEN tarihsel olarak halka açık bir repo'da oldukça geniş haklara sahiptir; onu işin gerçekten ihtiyaç duyduğuyla açıkça sınırlamak, zincirdeki bir üçüncü taraf action'ının ele geçirildiği ortaya çıkarsa hasarı sınırlar.

En iyi secret, var olmayan secret'tır. typescript-virtual-container'ın OIDC deseni, bu fikrin en tamamlanmış versiyonudur: bir NPM_TOKEN'ın döndürülmesini, süresinin dolmasını ve sızma riskini yönetmek yerine, iş akışı kriptografik olarak kimliğini (bu tam repo, bu tam iş akışı) doğrudan üçüncü taraf hizmete kanıtlar. Aynı mantık AWS, Docker Hub, PyPI için de mevcut -- giderek daha fazla kayıt defteri ve bulut, GitHub Actions'tan OIDC'yi destekliyor.


3 kilit nokta

  1. Bir git tag'i (öksüz, force-push edilmiş), minimalist bir veritabanı veya önceden derlenmiş derleme önbelleği olarak hizmet verebilir -- aynı mekanizmanın iki farklı kullanımı.
  2. Bedava bir GitHub Actions runner'ı, dosya sistemini inotifywait ile otomatik kaydedip tek bir değiştirilmiş commit ile bir git branch'ine sürekli senkronize etmeyi kabul ederseniz, kalıcı bir SSH kabuğu haline gelebilir.
  3. Varsayılan GITHUB_TOKEN kasıtlı olarak sınırlıdır -- branch'ler arası PR'lar oluşturmak veya secret'sız yayınlamak ya özel bir PAT ya da OIDC trusted publishing'e geçiş gerektirir.

5 modi ingegnosi di usare GitHub Actions (e cosa insegnano sui segreti)

Un runner CI trasformato in VPS gratuito, un bot che apre le proprie pull request, un publish npm senza alcun segreto. Un tour dei miei repo per catalogare pattern GitHub Actions che vanno oltre il classico \"lint + test + deploy\".

5 modi ingegnosi di usare GitHub Actions

Sulla carta, GitHub Actions serve per CI/CD classico: fai push, linta, testa, deploya. Ho già scritto su un caso particolare -- usare i git tag come database per un bot email (vedi l'articolo dedicato). Ma scavando nei miei repo, ci sono abbastanza pattern diversi da meritare un articolo a parte, meno focalizzato su un singolo progetto, più catalogo di tecniche.

Cinque cose, dalla più classica alla più contorta.

1. Un git tag come stato persistente tra un run e l'altro

Riepilogo rapido, i dettagli completi sono nell'articolo su email-autoreply. GitHub Actions è stateless per design -- ogni run parte da una macchina vergine. L'escamotage: salvare un valore (un ID, un timestamp, qualsiasi piccolo stato) in un git tag dedicato, mai in un branch.

# leggere lo stato
git show refs/tags/lastid:data/lastId > data/lastId

# scrivere lo stato (branch orfano, commit singolo, force-push del tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Il punto chiave: un branch orfano per non accumulare mai storia, e un tag forzato invece di un branch per non sporcare la lista dei branch del repo.

2. Un git tag come cache di build precompilata

Stessa famiglia di idee, altro uso: invece di salvare stato applicativo, si salva un artefatto di build. Il job build compila il codice una volta (al push su master), poi pusha dist/ + node_modules/ in un tag runtime. Il job cron fa checkout di quel tag direttamente invece di eseguire bun install && bun run build a ogni esecuzione:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# niente install, niente build -- il codice è già pronto
- run: node dist/index.js --action

Questo porta il run da ~20s a ~10s. Su un cron che gira spesso, conta. actions/cache fa un lavoro simile (cachare le dipendenze), ma un git tag è più diretto quando vuoi congelare completamente un artefatto versionato e puntarlo esplicitamente -- non solo velocizzare un npm install.

3. Un unico check obbligatorio che aggrega più job

Un piccolo pattern che non sembra granché ma che cambia la vita nella configurazione dei branch protetti. Su konosuba-rpg, la CI ha tre job indipendenti (typecheck, lint, tests) che girano in parallelo -- e un quarto job, test-battery, che non fa altro che dipendere dai primi tre:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Senza questo job facciata, configurare un branch protetto richiederebbe spuntare tre check obbligatori separati -- e aggiornare quella lista ogni volta che un job viene aggiunto o rinominato. Con test-battery, un solo nome da spuntare nelle impostazioni del repo, che resta stabile anche se i dettagli interni cambiano.

4. Trasformare un runner gratuito in un VPS temporaneo

Questo è il più contorto di tutti, e chiaramente il mio preferito: repo-to-vps dirotta completamente l'uso previsto di un runner GitHub Actions per farne una macchina Linux accessibile via SSH, gratis, fino a 6 ore (la durata massima di un job).

Il principio: un job che non fa quasi altro che lanciare tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Il vero grattacapo è che il filesystem di un runner GitHub Actions è usa e getta -- appena il job finisce, sparisce tutto. Una sessione SSH che dura ore non serve a niente se tutto quello che fai evapora al run successivo. La soluzione: un branch git che fa da snapshot live del filesystem, sincronizzato in continuo.

Lo script start-tmate.sh fa, in ordine:

  1. Ripristina il filesystem da un branch dedicato filesystem all'avvio del job (git reset --hard su di esso).
  2. Monitora i cambiamenti dei file in continuo con inotifywait, e committa + pusha immediatamente appena un file si muove:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Ogni salvataggio ammenda il commit precedente invece di crearne uno nuovo (git commit --amend --no-edit), quindi il branch filesystem resta sempre a un commit singolo -- nessun accumulo di migliaia di snapshot.
  2. Un ciclo while true rilancia tmate automaticamente se la sessione muore, con remain-on-exit on così che il terminale resti raggiungibile anche dopo un exit.
  3. L'URL SSH generato da tmate viene scritto in un file host.conf, committato sul branch filesystem -- recuperabile via API GitHub (gh api .../contents/host.conf) senza aver mai avuto accesso live ai log del job.
  4. Una routine periodic_save gira ogni 5 secondi in background, nel caso inotifywait perda un evento.

Risultato: una shell Linux completa, accessibile da qualsiasi parte, con un filesystem che persiste tra le sessioni -- anche se l'infrastruttura sottostante (un runner GitHub Actions) non è stata assolutamente progettata per questo. L'unico vero limite è il timeout di 6 ore per job -- dopo bisogna rilanciare il workflow.

5. Un bot che apre le proprie pull request

Su konosuba-rpg, un push sul branch dev attiva un job che verifica se esiste già una pull request aperta verso main -- e ne crea una automaticamente se non c'è, via actions/github-script e l'API REST di GitHub:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Il dettaglio che conta qui è il token usato. Questo workflow non usa il GITHUB_TOKEN automatico -- richiede un segreto AUTO_PR_TOKEN separato e si rifiuta di continuare se manca:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Pubblicare su npm senza alcun segreto

Il più discreto dei cinque, ma probabilmente il più importante per il futuro: il workflow publish.yml di typescript-virtual-container non contiene nessun segreto npm. Niente NPM_TOKEN, niente NODE_AUTH_TOKEN. Solo questo:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish funziona comunque, perché il registry npm ora supporta il trusted publishing via OIDC: il workflow dimostra la propria identità direttamente al registry (repo esatto + workflow esatto, configurati lato npmjs.org), senza che nessun token statico transiti o venga memorizzato da nessuna parte. Zero segreti da far trapelare, zero token da ruotare ogni sei mesi.


I segreti di GitHub, in profondità

Questi cinque pattern toccano tutti, in un modo o nell'altro, la questione dei segreti. Alcuni principi che ricorrono ovunque nei miei workflow:

Un segreto non è per forza una stringa semplice. In email-autoreply, ACCOUNTS_JSON contiene l'intero JSON minificato della configurazione multi-account -- non solo una chiave API, una struttura dati completa, iniettata tale e quale in un file a runtime:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Questo evita di dover committare un file di configurazione, anche cifrato, e si aggiorna con un clic nelle impostazioni del repo senza toccare il codice.

GITHUB_TOKEN ha limiti precisi, ed è voluto. Il token automatico che GitHub inietta a ogni run è potente, ma sigillato su certi punti: di default non può attivare un altro workflow, e a seconda della configurazione del repo può essere bloccato dalle regole di protezione dei branch. È proprio per questo che create-pull-request.yml richiede un PAT separato (AUTO_PR_TOKEN) -- un token da un account reale (o da una GitHub App), con diritti espliciti contents:write + pull-requests:write, distinto dal token effimero del job.

I permessi sono scoped job per job, non globalmente. Ogni workflow che ho elencato qui dichiara un blocco permissions: minimo e commentato:

permissions:
  contents: read
  actions: read
  checks: write

Il GITHUB_TOKEN predefinito storicamente ha diritti piuttosto ampi su un repo pubblico; restringerlo esplicitamente a ciò di cui il job ha realmente bisogno limita i danni se un'action di terze parti nella catena risulta compromessa.

Il miglior segreto è quello che non esiste. Il pattern OIDC di typescript-virtual-container è la versione più compiuta di questa idea: invece di gestire rotazione, scadenza e rischio di fuga di un NPM_TOKEN, il workflow prova crittograficamente la propria identità (questo esatto repo, questo esatto workflow) direttamente al servizio terzo. Stessa logica disponibile per AWS, Docker Hub, PyPI -- sempre più registry e cloud supportano OIDC da GitHub Actions.


3 punti chiave

  1. Un git tag (orfano, force-pushato) può fungere da database minimalista o cache di build precompilata -- due usi distinti dello stesso meccanismo.
  2. Un runner GitHub Actions gratuito può diventare una shell SSH persistente se accetti di sincronizzare in continuo il suo filesystem verso un branch git, con autosalvataggio via inotifywait e un singolo commit emendato.
  3. Il GITHUB_TOKEN predefinito è volutamente limitato -- creare PR tra branch o pubblicare senza segreti richiede o un PAT dedicato, o il passaggio all'OIDC trusted publishing.

5 raffinierte Arten, GitHub Actions zu nutzen (und was sie über Secrets lehren)

Ein CI-Runner als kostenloser VPS, ein Bot der seine eigenen Pull-Requests öffnet, ein npm-Publish ohne jedes Secret. Ein Streifzug durch meine Repos als Katalog von GitHub-Actions-Patterns jenseits von \"lint + test + deploy\".

5 raffinierte Arten, GitHub Actions zu nutzen

Auf dem Papier ist GitHub Actions für klassisches CI/CD da: du pushst, es lintet, testet, deployed. Ich hab schon über einen Spezialfall geschrieben -- git-Tags als Datenbank für einen E-Mail-Bot nutzen (siehe den eigenen Artikel). Aber beim Durchforsten meiner eigenen Repos sind genug verschiedene Patterns zusammengekommen, dass es einen eigenen Artikel wert ist -- weniger auf ein einzelnes Projekt fokussiert, mehr ein Technik-Katalog.

Fünf Dinger, vom Klassischsten bis zum Abgefahrensten.

1. Ein git-Tag als persistenter Zustand zwischen Runs

Kurze Wiederholung, die Details stehen im email-autoreply-Artikel. GitHub Actions ist per Design zustandslos -- jeder Run startet auf einer jungfräulichen Maschine. Der Workaround: einen Wert (eine ID, einen Timestamp, jedes kleine Stück Zustand) in einem dedizierten git-Tag speichern, nie in einem Branch.

# Zustand lesen
git show refs/tags/lastid:data/lastId > data/lastId

# Zustand schreiben (orphan branch, ein Commit, Tag force-pushen)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Der Knackpunkt: ein Orphan-Branch, um niemals History anzusammeln, und ein forced Tag statt eines Branches, um die Branch-Liste des Repos nicht zuzumüllen.

2. Ein git-Tag als vorkompilierter Build-Cache

Gleiche Ideenfamilie, andere Nutzung: statt Applikationszustand speichert man ein Build-Artefakt. Der build-Job kompiliert den Code einmal (beim Push auf master) und pusht dist/ + node_modules/ in einen runtime-Tag. Der cron-Job checkt diesen Tag direkt aus, statt bei jeder Ausführung bun install && bun run build durchlaufen zu lassen:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# kein install, kein build -- der Code ist fertig
- run: node dist/index.js --action

Das bringt den Run von ~20s auf ~10s. Bei einem Cron, der oft läuft, macht das was aus. actions/cache macht was Ähnliches (Abhängigkeiten cachen), aber ein git-Tag ist direkter, wenn man ein versioniertes Artefakt komplett einfrieren und explizit referenzieren will -- nicht nur ein npm install beschleunigen.

3. Ein einziger Required Check, der mehrere Jobs bündelt

Ein kleines Pattern, das unscheinbar wirkt, aber die Branch-Protection-Konfiguration radikal vereinfacht. Bei konosuba-rpg hat die CI drei unabhängige Jobs (typecheck, lint, tests), die parallel laufen -- und einen vierten Job, test-battery, der nichts anderes tut, als von den ersten drei abzuhängen:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Ohne diesen Fassaden-Job müsste man beim Einrichten eines Protected Branches drei separate Checks anhaken -- und diese Liste jedes Mal aktualisieren, wenn ein Job hinzukommt oder umbenannt wird. Mit test-battery reicht ein einziger Name in den Repo-Settings, der stabil bleibt, auch wenn sich die internen Details ändern.

4. Einen kostenlosen Runner in eine temporäre VPS verwandeln

Das ist das abgefahrenste von allen und eindeutig mein Liebling: repo-to-vps zweckentfremdet einen GitHub-Actions-Runner komplett, um daraus eine Linux-Maschine zu machen, die per SSH erreichbar ist. Kostenlos. Bis zu 6 Stunden (die maximale Job-Dauer).

Das Prinzip: ein Job, der fast nichts anderes tut, als tmate zu starten:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Die echte Krux: Das Dateisystem eines GitHub-Actions-Runners ist flüchtig -- sobald der Job endet, ist alles weg. Eine SSH-Sitzung, die stundenlang läuft, bringt nix, wenn alles, was man tut, beim nächsten Run verdampft. Die Lösung: ein git-Branch, der als Live-Snapshot des Dateisystems dient, kontinuierlich synchronisiert.

Das start-tmate.sh-Skript macht, der Reihe nach:

  1. Stellt das Dateisystem von einem dedizierten filesystem-Branch beim Job-Start wieder her (git reset --hard drauf).
  2. Überwacht Dateiänderungen kontinuierlich mit inotifywait und committet + pusht sofort, sobald eine Datei sich bewegt:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Jede Speicherung amendet den vorherigen Commit, statt einen neuen zu erstellen (git commit --amend --no-edit), sodass der filesystem-Branch immer bei genau einem Commit bleibt -- keine Anhäufung von Tausenden Snapshots.
  2. Eine while true-Schleife startet tmate automatisch neu, falls die Sitzung stirbt, mit remain-on-exit on, damit das Terminal auch nach einem exit erreichbar bleibt.
  3. Die von tmate generierte SSH-URL wird in eine host.conf-Datei geschrieben, in den filesystem-Branch committet -- abrufbar über die GitHub-API (gh api .../contents/host.conf), ohne jemals Live-Zugriff auf die Job-Logs gehabt zu haben.
  4. Eine periodic_save-Routine läuft alle 5 Sekunden im Hintergrund, falls inotifywait ein Event verpasst.

Ergebnis: eine vollständige Linux-Shell, von überall erreichbar, mit einem Dateisystem, das zwischen Sessions persistiert -- obwohl die zugrundeliegende Infrastruktur (ein GitHub-Actions-Runner) absolut nicht dafür gebaut wurde. Die einzige echte Grenze ist der 6-Stunden-Timeout pro Job -- danach muss man den Workflow neu starten.

5. Ein Bot, der seine eigenen Pull-Requests öffnet

Auf konosuba-rpg triggert ein Push auf den dev-Branch einen Job, der prüft, ob bereits ein offener PR nach main existiert -- und erstellt automatisch einen, falls nicht, via actions/github-script und der GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Das entscheidende Detail hier ist das verwendete Token. Dieser Workflow nutzt nicht das automatische GITHUB_TOKEN -- er verlangt ein separates AUTO_PR_TOKEN-Secret und weigert sich weiterzumachen, wenn es fehlt:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Auf npm publishen ohne jedes Secret

Das leiseste der fünf, aber wahrscheinlich das wichtigste für die Zukunft: der publish.yml-Workflow von typescript-virtual-container enthält keine npm-Secrets. Kein NPM_TOKEN, kein NODE_AUTH_TOKEN. Nur das:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish funktioniert trotzdem, weil die npm-Registry jetzt Trusted Publishing via OIDC unterstützt: der Workflow weist seine Identität direkt gegenüber der Registry nach (exaktes Repo + exakter Workflow, auf npmjs.org-Seite konfiguriert), ohne dass irgendein statisches Token übertragen oder gespeichert wird. Null Secrets, die leaken könnten, null Tokens, die man alle sechs Monate rotieren muss.


GitHub Secrets, in der Tiefe

Diese fünf Patterns berühren alle auf die eine oder andere Weise die Frage der Secrets. Ein paar Prinzipien, die sich durch all meine Workflows ziehen:

Ein Secret ist nicht unbedingt ein einfacher String. Bei email-autoreply enthält ACCOUNTS_JSON das gesamte minifizierte JSON der Multi-Account-Konfiguration -- nicht nur ein API-Key, eine komplette Datenstruktur, zur Laufzeit eins-zu-eins in eine Datei injiziert:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Das vermeidet, eine Config-Datei committen zu müssen, selbst verschlüsselt, und lässt sich mit einem Klick in den Repo-Settings aktualisieren, ohne den Code anzufassen.

GITHUB_TOKEN hat präzise Grenzen, und das ist Absicht. Das automatische Token, das GitHub bei jedem Run injiziert, ist mächtig, aber an bestimmten Punkten versiegelt: standardmäßig kann es keinen anderen Workflow triggern, und je nach Repo-Konfiguration kann es von Branch-Protection-Regeln blockiert werden. Genau deshalb verlangt create-pull-request.yml ein separates PAT (AUTO_PR_TOKEN) -- ein Token von einem echten Account (oder einer GitHub App), mit expliziten contents:write + pull-requests:write-Rechten, getrennt vom ephemeren Token des Jobs.

Berechtigungen werden Job für Job gescoped, nicht global. Jeder Workflow, den ich hier aufgelistet habe, deklariert einen minimalen, kommentierten permissions:-Block:

permissions:
  contents: read
  actions: read
  checks: write

Das standardmäßige GITHUB_TOKEN hat historisch ziemlich breite Rechte auf einem öffentlichen Repo; es explizit auf das einzuschränken, was der Job tatsächlich braucht, begrenzt den Schaden, falls eine Drittanbieter-Action in der Kette kompromittiert wird.

Das beste Secret ist das, das nicht existiert. Das OIDC-Pattern von typescript-virtual-container ist die vollendetste Version dieser Idee: statt Rotation, Ablauf und Leak-Risiko eines NPM_TOKEN zu managen, weist der Workflow kryptografisch seine Identität (dieses exakte Repo, dieser exakte Workflow) direkt gegenüber dem Drittdienst nach. Gleiche Logik verfügbar für AWS, Docker Hub, PyPI -- immer mehr Registries und Clouds unterstützen OIDC von GitHub Actions aus.


3 Kernpunkte

  1. Ein git-Tag (orphan, force-pushed) kann als minimalist Datenbank oder vorkompilierter Build-Cache dienen -- zwei unterschiedliche Nutzungen desselben Mechanismus.
  2. Ein kostenloser GitHub-Actions-Runner kann zu einer persistenten SSH-Shell werden, wenn man akzeptiert, sein Dateisystem kontinuierlich via inotifywait in einen git-Branch zu synchronisieren, mit einem einzigen amendeten Commit.
  3. Das standardmäßige GITHUB_TOKEN ist absichtlich limitiert -- branchübergreifende PRs zu erstellen oder ohne Secrets zu publishen erfordert entweder ein dediziertes PAT oder den Umstieg auf OIDC Trusted Publishing.

5 изобретательных способов использовать GitHub Actions (и чему они учат о секретах)

CI-раннер, превращённый в бесплатный VPS, бот, открывающий себе pull request'ы, npm-публикация без единого секрета. Обход моих репозиториев -- каталог паттернов GitHub Actions, выходящих за рамки \"lint + test + deploy\".

5 изобретательных способов использовать GitHub Actions

На бумаге GitHub Actions нужен для классического CI/CD: ты пушишь, он линтит, тестирует, деплоит. Я уже писал об одном частном случае -- использование git-тегов как базы данных для email-бота (см. отдельную статью). Но копаясь в собственных репозиториях, я набрал достаточно разных паттернов, чтобы это заслуживало отдельной статьи -- меньше фокуса на одном проекте, больше каталог техник.

Пять штук, от самой классической до самой извращённой.

1. Git-тег как персистентное состояние между запусками

Краткое напоминание, подробности в статье про email-autoreply. GitHub Actions по дизайну не имеет состояния -- каждый запуск стартует с чистой машины. Обходной путь: хранить значение (ID, временную метку, любой мелкий стейт) в специальном git-теге, никогда не в ветке.

# прочитать состояние
git show refs/tags/lastid:data/lastId > data/lastId

# записать состояние (сиротская ветка, один коммит, force-push тега)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Ключевой момент: сиротская ветка, чтобы никогда не накапливать историю, и принудительный тег вместо ветки, чтобы не засорять список веток репозитория.

2. Git-тег как прекомпилированный кеш сборки

Та же семья идей, другое применение: вместо хранения состояния приложения храним артефакт сборки. Джоба build компилирует код один раз (при пуше в master), затем пушит dist/ + node_modules/ в тег runtime. Джоба cron чекаутит этот тег напрямую вместо запуска bun install && bun run build при каждом выполнении:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# без install, без build -- код уже готов
- run: node dist/index.js --action

Это сокращает время выполнения с ~20с до ~10с. На cron'е, который крутится часто, это имеет значение. actions/cache делает похожую работу (кеширует зависимости), но git-тег более прямолинеен, когда нужно полностью заморозить версионированный артефакт и явно на него ссылаться -- а не просто ускорять npm install.

3. Один обязательный чек, агрегирующий несколько джоб

Маленький паттерн, который выглядит незначительным, но меняет жизнь в настройке защиты веток. На konosuba-rpg CI имеет три независимые джобы (typecheck, lint, tests), работающие параллельно -- и четвёртую джобу, test-battery, которая не делает ничего, кроме как зависит от первых трёх:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Без этой фасадной джобы настройка защищённой ветки требовала бы отмечать три отдельных обязательных чека -- и обновлять этот список каждый раз при добавлении или переименовании джобы. С test-battery -- одно имя для отметки в настройках репозитория, которое остаётся стабильным, даже если внутренние детали меняются.

4. Превращение бесплатного раннера во временный VPS

Это самая извращённая из всех, и определённо моя любимая: repo-to-vps полностью извращает предполагаемое использование раннера GitHub Actions, превращая его в Linux-машину, доступную по SSH. Бесплатно. До 6 часов (максимальная длительность джобы).

Принцип: джоба, которая почти ничего не делает, кроме запуска tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Настоящая проблема в том, что файловая система раннера GitHub Actions одноразовая -- как только джоба заканчивается, всё исчезает. SSH-сессия, длящаяся часами, бесполезна, если всё, что ты делаешь, испаряется при следующем запуске. Решение: git-ветка, служащая живым снимком файловой системы, непрерывно синхронизируемая.

Скрипт start-tmate.sh делает, по порядку:

  1. Восстанавливает файловую систему из выделенной ветки filesystem при старте джобы (git reset --hard на неё).
  2. Следит за изменениями файлов непрерывно с помощью inotifywait, и немедленно коммитит + пушит, как только файл меняется:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Каждое сохранение изменяет предыдущий коммит вместо создания нового (git commit --amend --no-edit), так что ветка filesystem всегда остаётся на одном коммите -- никакого накопления тысяч снимков.
  2. Цикл while true перезапускает tmate автоматически, если сессия умирает, с remain-on-exit on, чтобы терминал оставался доступным даже после exit.
  3. SSH URL, сгенерированный tmate, записывается в файл host.conf, коммитится в ветку filesystem -- извлекается через GitHub API (gh api .../contents/host.conf) без необходимости живого доступа к логам джобы.
  4. Рутина periodic_save работает каждые 5 секунд в фоне, на случай если inotifywait пропустит событие.

Результат: полноценный Linux-шелл, доступный отовсюду, с файловой системой, сохраняющейся между сессиями -- при том, что нижележащая инфраструктура (раннер GitHub Actions) абсолютно не была для этого спроектирована. Единственное реальное ограничение -- 6-часовой таймаут на джобу -- после чего нужно перезапустить workflow.

5. Бот, открывающий себе pull request'ы

На konosuba-rpg пуш в ветку dev запускает джобу, которая проверяет, существует ли уже открытый PR в main -- и создаёт его автоматически, если нет, через actions/github-script и GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Важная деталь здесь -- используемый токен. Этот workflow не использует автоматический GITHUB_TOKEN -- он требует отдельный секрет AUTO_PR_TOKEN и отказывается продолжать, если его нет:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Публикация в npm без единого секрета

Самый тихий из пяти, но, вероятно, самый важный для будущего: workflow publish.yml из typescript-virtual-container не содержит ни одного npm-секрета. Ни NPM_TOKEN, ни NODE_AUTH_TOKEN. Только это:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish всё равно работает, потому что реестр npm теперь поддерживает trusted publishing через OIDC: workflow доказывает свою идентичность напрямую реестру (точный репозиторий + точный workflow, настроенные на стороне npmjs.org), без передачи или хранения статического токена где бы то ни было. Ноль секретов для утечки, ноль токенов для ротации каждые полгода.


Секреты GitHub, в глубину

Эти пять паттернов все так или иначе касаются вопроса секретов. Несколько принципов, повторяющихся во всех моих workflow:

Секрет -- не обязательно простая строка. В email-autoreply ACCOUNTS_JSON содержит весь минифицированный JSON многопрофильной конфигурации -- не просто API-ключ, а целую структуру данных, внедряемую как есть в файл во время выполнения:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Это избавляет от необходимости коммитить конфигурационный файл, даже зашифрованный, и обновляется одним кликом в настройках репозитория без касания кода.

GITHUB_TOKEN имеет точные ограничения, и это намеренно. Автоматический токен, который GitHub внедряет в каждый запуск, мощный, но запечатан в определённых точках: по умолчанию он не может запускать другой workflow, и в зависимости от настроек репозитория может быть заблокирован правилами защиты веток. Именно поэтому create-pull-request.yml требует отдельный PAT (AUTO_PR_TOKEN) -- токен от реального аккаунта (или GitHub App), с явными правами contents:write + pull-requests:write, отдельный от эфемерного токена джобы.

Разрешения скопятся поджобно, а не глобально. Каждый workflow, перечисленный здесь, объявляет минимальный, закомментированный блок permissions::

permissions:
  contents: read
  actions: read
  checks: write

У GITHUB_TOKEN по умолчанию исторически довольно широкие права на публичном репозитории; явное ограничение только тем, что джобе реально нужно, ограничивает ущерб, если сторонний action в цепочке окажется скомпрометирован.

Лучший секрет -- тот, которого нет. Паттерн OIDC из typescript-virtual-container -- самая завершённая версия этой идеи: вместо управления ротацией, истечением и риском утечки NPM_TOKEN, workflow криптографически доказывает свою идентичность (этот точный репозиторий, этот точный workflow) напрямую стороннему сервису. Та же логика доступна для AWS, Docker Hub, PyPI -- всё больше реестров и облаков поддерживают OIDC из GitHub Actions.


3 ключевых момента

  1. Git-тег (сиротский, force-push) может служить минималистичной базой данных или прекомпилированным кешем сборки -- два разных применения одного механизма.
  2. Бесплатный раннер GitHub Actions может стать персистентной SSH-оболочкой, если принять непрерывную синхронизацию его файловой системы в git-ветку с автосохранением через inotifywait и одним amend-коммитом.
  3. GITHUB_TOKEN по умолчанию намеренно ограничен -- создание межветочных PR или публикация без секретов требует либо выделенного PAT, либо перехода на OIDC trusted publishing.

5 formas ingeniosas de usar GitHub Actions (y lo que enseñan sobre los secretos)

Un runner CI convertido en VPS gratuito, un bot que abre sus propias pull requests, un publish npm sin un solo secreto. Un recorrido por mis repos para catalogar patrones de GitHub Actions que van más allá de \"lint + test + deploy\".

5 formas ingeniosas de usar GitHub Actions

Sobre el papel, GitHub Actions sirve para CI/CD clásico: haces push, lintea, testea, despliega. Ya escribí sobre un caso especial -- usar git tags como base de datos para un bot de email (ver el artículo dedicado). Pero rebuscando en mis propios repos, hay suficientes patrones distintos como para merecer un artículo aparte, menos centrado en un solo proyecto, más catálogo de técnicas.

Cinco cosas, de la más clásica a la más retorcida.

1. Un git tag como estado persistente entre ejecuciones

Resumen rápido, los detalles completos están en el artículo de email-autoreply. GitHub Actions es sin estado por diseño -- cada ejecución parte de una máquina limpia. La solución: almacenar un valor (un ID, un timestamp, cualquier pequeño estado) en un git tag dedicado, nunca en una rama.

# leer estado
git show refs/tags/lastid:data/lastId > data/lastId

# escribir estado (rama huérfana, un solo commit, force-push del tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

El punto clave: una rama huérfana para nunca acumular historial, y un tag forzado en vez de una rama para no contaminar la lista de ramas del repo.

2. Un git tag como caché de build precompilado

Misma familia de ideas, otro uso: en vez de guardar estado de aplicación, se guarda un artefacto de build. El job build compila el código una vez (en push a master) y luego sube dist/ + node_modules/ a un tag runtime. El job cron hace checkout de ese tag directamente en vez de ejecutar bun install && bun run build cada vez:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# sin install, sin build -- el código ya está listo
- run: node dist/index.js --action

Esto cambia la ejecución de ~20s a ~10s. En un cron que corre seguido, importa. actions/cache hace un trabajo similar (cachear dependencias), pero un git tag es más directo cuando quieres congelar completamente un artefacto versionado y apuntarlo explícitamente -- no solo acelerar un npm install.

3. Un solo check obligatorio que agrupa varios jobs

Un pequeño patrón que no parece gran cosa pero que cambia la vida en la configuración de ramas protegidas. En konosuba-rpg, la CI tiene tres jobs independientes (typecheck, lint, tests) que corren en paralelo -- y un cuarto job, test-battery, que no hace nada más que depender de los tres primeros:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Sin este job fachada, configurar una rama protegida requeriría marcar tres checks obligatorios separados -- y actualizar esa lista cada vez que se añade o renombra un job. Con test-battery, un solo nombre que marcar en los ajustes del repo, estable aunque cambien los detalles internos.

4. Convertir un runner gratuito en un VPS temporal

Este es el más retorcido de todos, y claramente mi favorito: repo-to-vps desvía completamente el uso previsto de un runner de GitHub Actions para convertirlo en una máquina Linux accesible por SSH, gratis, hasta 6 horas (la duración máxima de un job).

El principio: un job que no hace casi nada más que lanzar tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

El verdadero problema es que el sistema de archivos de un runner de GitHub Actions es desechable -- en cuanto termina el job, todo desaparece. Una sesión SSH que dura horas no sirve de nada si todo lo que haces se evapora en la siguiente ejecución. La solución: una rama git que sirve de snapshot en vivo del sistema de archivos, sincronizada continuamente.

El script start-tmate.sh hace, por orden:

  1. Restaura el sistema de archivos desde una rama dedicada filesystem al iniciar el job (git reset --hard sobre ella).
  2. Vigila los cambios de archivos continuamente con inotifywait, y hace commit + push inmediatamente en cuanto un archivo se mueve:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Cada guardado enmienda el commit anterior en vez de crear uno nuevo (git commit --amend --no-edit), así que la rama filesystem siempre está en un solo commit -- sin acumulación de miles de snapshots.
  2. Un bucle while true relanza tmate automáticamente si la sesión muere, con remain-on-exit on para que el terminal siga siendo accesible incluso después de un exit.
  3. La URL SSH generada por tmate se escribe en un archivo host.conf, commiteado en la rama filesystem -- recuperable vía la API de GitHub (gh api .../contents/host.conf) sin haber tenido nunca acceso en vivo a los logs del job.
  4. Una rutina periodic_save corre cada 5 segundos en segundo plano, por si inotifywait se pierde algún evento.

Resultado: un shell Linux completo, accesible desde cualquier sitio, con un sistema de archivos que persiste entre sesiones -- aunque la infraestructura subyacente (un runner de GitHub Actions) no fue diseñada para nada de esto. El único límite real es el timeout de 6 horas por job -- después hay que relanzar el workflow.

5. Un bot que abre sus propias pull requests

En konosuba-rpg, un push a la rama dev dispara un job que comprueba si ya existe una pull request abierta hacia main -- y crea una automáticamente si no, mediante actions/github-script y la API REST de GitHub:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

El detalle que importa aquí es el token usado. Este workflow no usa el GITHUB_TOKEN automático -- exige un secreto AUTO_PR_TOKEN aparte, y se niega a continuar si falta:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Publicar en npm sin ningún secreto

El más discreto de los cinco, pero probablemente el más importante para el futuro: el workflow publish.yml de typescript-virtual-container no contiene ningún secreto npm. Sin NPM_TOKEN, sin NODE_AUTH_TOKEN. Solo esto:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish funciona igual porque el registro npm ahora soporta trusted publishing vía OIDC: el workflow demuestra su identidad directamente al registro (repo exacto + workflow exacto, configurados en npmjs.org), sin que ningún token estático transite ni se almacene en ningún sitio. Cero secretos que puedan filtrarse, cero tokens que rotar cada seis meses.


Los secretos de GitHub, en profundidad

Estos cinco patrones tocan todos, de una forma u otra, la cuestión de los secretos. Algunos principios que se repiten en todos mis workflows:

Un secreto no es necesariamente una cadena simple. En email-autoreply, ACCOUNTS_JSON contiene el JSON minificado entero de la configuración multi-cuenta -- no solo una clave API, una estructura de datos completa, inyectada tal cual en un archivo en tiempo de ejecución:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Esto evita tener que commitear un archivo de configuración, incluso cifrado, y se actualiza con un clic en los ajustes del repo sin tocar el código.

GITHUB_TOKEN tiene límites precisos, y es a propósito. El token automático que GitHub inyecta en cada ejecución es potente, pero sellado en ciertos puntos: por defecto no puede disparar otro workflow, y según la configuración del repo puede ser bloqueado por reglas de protección de rama. Justo por eso create-pull-request.yml exige un PAT aparte (AUTO_PR_TOKEN) -- un token de una cuenta real (o de una GitHub App), con derechos explícitos contents:write + pull-requests:write, distinto del token efímero del job.

Los permisos se scopean job por job, no globalmente. Cada workflow que he listado aquí declara un bloque permissions: mínimo y comentado:

permissions:
  contents: read
  actions: read
  checks: write

El GITHUB_TOKEN por defecto tiene históricamente derechos bastante amplios sobre un repo público; restringirlo explícitamente a lo que el job realmente necesita limita el daño si una action de terceros en la cadena resulta comprometida.

El mejor secreto es el que no existe. El patrón OIDC de typescript-virtual-container es la versión más lograda de esta idea: en vez de gestionar la rotación, expiración y riesgo de fuga de un NPM_TOKEN, el workflow demuestra criptográficamente su identidad (este repo exacto, este workflow exacto) directamente al servicio tercero. Misma lógica disponible para AWS, Docker Hub, PyPI -- cada vez más registros y clouds soportan OIDC desde GitHub Actions.


3 puntos clave

  1. Un git tag (huérfano, force-pusheado) puede servir como base de datos minimalista o caché de build precompilado -- dos usos distintos del mismo mecanismo.
  2. Un runner gratuito de GitHub Actions puede convertirse en un shell SSH persistente si aceptas sincronizar continuamente su sistema de archivos hacia una rama git, con autoguardado por inotifywait y un solo commit enmendado.
  3. El GITHUB_TOKEN por defecto está limitado a propósito -- crear PRs entre ramas o publicar sin secretos requiere o bien un PAT dedicado, o bien pasarse a OIDC trusted publishing.

5 formas engenhosas de usar o GitHub Actions (e o que ensinam sobre secrets)

Um runner CI transformado em VPS gratuito, um bot que abre as suas próprias pull requests, um publish npm sem secret nenhum. Um passeio pelos meus repos para catalogar padrões de GitHub Actions que vão além do \"lint + test + deploy\".

5 formas engenhosas de usar o GitHub Actions

No papel, o GitHub Actions serve para CI/CD clássico: fazes push, ele faz lint, testa, faz deploy. Já escrevi sobre um caso especial -- usar git tags como base de dados para um bot de email (ver o artigo dedicado). Mas a vasculhar os meus próprios repos, há padrões diferentes que cheguem para merecer um artigo à parte, menos focado num só projeto, mais catálogo de técnicas.

Cinco coisas, da mais clássica à mais retorcida.

1. Uma git tag como estado persistente entre execuções

Recapitulação rápida, os detalhes completos estão no artigo sobre email-autoreply. O GitHub Actions é stateless por design -- cada execução parte de uma máquina limpa. A solução: guardar um valor (um ID, um timestamp, qualquer estado pequeno) numa git tag dedicada, nunca num branch.

# ler estado
git show refs/tags/lastid:data/lastId > data/lastId

# escrever estado (branch órfão, um só commit, force-push da tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

O ponto-chave: um branch órfão para nunca acumular histórico, e uma tag forçada em vez de um branch para não poluir a lista de branches do repo.

2. Uma git tag como cache de build pré-compilado

Mesma família de ideias, uso diferente: em vez de guardar estado de aplicação, guarda-se um artefacto de build. O job build compila o código uma vez (no push para master) e depois faz push de dist/ + node_modules/ para uma tag runtime. O job cron faz checkout dessa tag diretamente em vez de correr bun install && bun run build a cada execução:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# sem install, sem build -- o código já está pronto
- run: node dist/index.js --action

Isto muda a execução de ~20s para ~10s. Num cron que corre frequentemente, faz diferença. O actions/cache faz um trabalho semelhante (cache de dependências), mas uma git tag é mais direta quando queres congelar completamente um artefacto versionado e apontá-lo explicitamente -- não só acelerar um npm install.

3. Um único check obrigatório que agrega vários jobs

Um pequeno padrão que não parece nada de especial mas que muda a vida na configuração de branches protegidos. No konosuba-rpg, a CI tem três jobs independentes (typecheck, lint, tests) a correr em paralelo -- e um quarto job, test-battery, que não faz nada além de depender dos três primeiros:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Sem este job fachada, configurar um branch protegido exigiria marcar três checks obrigatórios separados -- e atualizar essa lista cada vez que um job é adicionado ou renomeado. Com o test-battery, um único nome para marcar nas definições do repo, que permanece estável mesmo que os detalhes internos mudem.

4. Transformar um runner gratuito num VPS temporário

Este é o mais retorcido de todos, e claramente o meu favorito: o repo-to-vps desvia completamente o uso previsto de um runner do GitHub Actions para o transformar numa máquina Linux acessível por SSH, gratuita, até 6 horas (a duração máxima de um job).

O princípio: um job que não faz quase nada além de lançar o tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

O verdadeiro problema é que o sistema de ficheiros de um runner do GitHub Actions é descartável -- assim que o job termina, tudo desaparece. Uma sessão SSH que dura horas não serve de nada se tudo o que fazes se evapora na execução seguinte. A solução: um branch git que serve de snapshot ao vivo do sistema de ficheiros, sincronizado continuamente.

O script start-tmate.sh faz, por ordem:

  1. Restaura o sistema de ficheiros a partir de um branch dedicado filesystem no arranque do job (git reset --hard sobre ele).
  2. Vigia as alterações de ficheiros continuamente com inotifywait, e faz commit + push imediatamente assim que um ficheiro se mexe:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Cada gravação emenda o commit anterior em vez de criar um novo (git commit --amend --no-edit), por isso o branch filesystem fica sempre num único commit -- sem acumulação de milhares de snapshots.
  2. Um ciclo while true relança o tmate automaticamente se a sessão morrer, com remain-on-exit on para que o terminal continue acessível mesmo depois de um exit.
  3. O URL SSH gerado pelo tmate é escrito num ficheiro host.conf, commitado no branch filesystem -- recuperável via a API do GitHub (gh api .../contents/host.conf) sem nunca ter tido acesso em direto aos logs do job.
  4. Uma rotina periodic_save corre a cada 5 segundos em segundo plano, caso o inotifywait perca algum evento.

Resultado: uma shell Linux completa, acessível de qualquer lado, com um sistema de ficheiros que persiste entre sessões -- apesar de a infraestrutura subjacente (um runner do GitHub Actions) não ter sido absolutamente nada concebida para isto. O único limite real é o timeout de 6 horas por job -- depois disso é preciso relançar o workflow.

5. Um bot que abre as suas próprias pull requests

No konosuba-rpg, um push para o branch dev dispara um job que verifica se já existe uma pull request aberta para main -- e cria uma automaticamente se não existir, via actions/github-script e a API REST do GitHub:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

O detalhe que importa aqui é o token usado. Este workflow não usa o GITHUB_TOKEN automático -- exige um secret AUTO_PR_TOKEN separado e recusa-se a continuar se este faltar:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Publicar no npm sem secret nenhum

O mais discreto dos cinco, mas provavelmente o mais importante para o futuro: o workflow publish.yml do typescript-virtual-container não contém nenhum secret npm. Sem NPM_TOKEN, sem NODE_AUTH_TOKEN. Apenas isto:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

O npm publish funciona na mesma, porque o registry do npm agora suporta trusted publishing via OIDC: o workflow prova a sua identidade diretamente ao registry (repo exato + workflow exato, configurados do lado do npmjs.org), sem que nenhum token estático transite ou seja armazenado em lado nenhum. Zero secrets para escapar, zero tokens para rodar de seis em seis meses.


Os secrets do GitHub, em profundidade

Estes cinco padrões tocam todos, de uma forma ou de outra, na questão dos secrets. Alguns princípios que se repetem em todos os meus workflows:

Um secret não é necessariamente uma string simples. No email-autoreply, o ACCOUNTS_JSON contém o JSON minificado inteiro da configuração multi-conta -- não apenas uma chave API, uma estrutura de dados completa, injetada tal e qual num ficheiro em runtime:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Isto evita ter de fazer commit de um ficheiro de configuração, mesmo encriptado, e atualiza-se com um clique nas definições do repo sem tocar no código.

O GITHUB_TOKEN tem limites precisos, e é de propósito. O token automático que o GitHub injeta em cada execução é poderoso, mas selado em certos pontos: por padrão, não pode disparar outro workflow, e dependendo da configuração do repo pode ser bloqueado por regras de proteção de branch. É exatamente por isso que o create-pull-request.yml exige um PAT separado (AUTO_PR_TOKEN) -- um token de uma conta real (ou de uma GitHub App), com direitos explícitos contents:write + pull-requests:write, distinto do token efémero do job.

As permissões são scopeadas job a job, não globalmente. Cada workflow que listei aqui declara um bloco permissions: mínimo e comentado:

permissions:
  contents: read
  actions: read
  checks: write

O GITHUB_TOKEN padrão tem historicamente direitos bastante amplos sobre um repo público; restringi-lo explicitamente ao que o job realmente precisa limita os danos se uma action de terceiros na cadeia se revelar comprometida.

O melhor secret é aquele que não existe. O padrão OIDC do typescript-virtual-container é a versão mais conseguida desta ideia: em vez de gerir a rotação, expiração e risco de fuga de um NPM_TOKEN, o workflow prova criptograficamente a sua identidade (este repo exato, este workflow exato) diretamente ao serviço terceiro. Mesma lógica disponível para AWS, Docker Hub, PyPI -- cada vez mais registries e clouds suportam OIDC a partir do GitHub Actions.


3 pontos-chave

  1. Uma git tag (órfã, force-pushada) pode servir como base de dados minimalista ou cache de build pré-compilado -- dois usos distintos do mesmo mecanismo.
  2. Um runner gratuito do GitHub Actions pode tornar-se uma shell SSH persistente se aceitares sincronizar continuamente o seu sistema de ficheiros para um branch git, com auto-save via inotifywait e um único commit emendado.
  3. O GITHUB_TOKEN padrão é limitado de propósito -- criar PRs entre branches ou publicar sem secrets requer ou um PAT dedicado, ou a mudança para OIDC trusted publishing.

5 cara cerdik menggunakan GitHub Actions (dan apa yang diajarkan tentang secrets)

CI runner disulap jadi VPS gratis, bot yang membuka PR-nya sendiri, publish npm tanpa secret sama sekali. Tur ke repo-repo saya untuk membuat katalog pola GitHub Actions yang melampaui \"lint + test + deploy\".

5 cara cerdik menggunakan GitHub Actions

Di atas kertas, GitHub Actions untuk CI/CD klasik: kamu push, dia lint, test, deploy. Saya sudah menulis tentang kasus khusus -- menggunakan git tag sebagai database untuk bot email (lihat artikel khusus). Tapi menggali repo-repo saya sendiri, ada cukup banyak pola berbeda sehingga layak dibuat artikel tersendiri, kurang fokus pada satu proyek, lebih seperti katalog teknik.

Lima hal, dari yang paling klasik sampai paling nyeleneh.

1. Git tag sebagai state persisten antar run

Rekap cepat, detail lengkapnya di artikel email-autoreply. GitHub Actions didesain stateless -- setiap run mulai dari mesin kosong. Akal-akalannya: simpan sebuah nilai (ID, timestamp, state kecil apa pun) di git tag khusus, jangan pernah di branch.

# baca state
git show refs/tags/lastid:data/lastId > data/lastId

# tulis state (orphan branch, commit tunggal, force-push tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Poin kuncinya: orphan branch agar tidak pernah menumpuk history, dan forced tag alih-alih branch agar tidak mengotori daftar branch repo.

2. Git tag sebagai cache build yang sudah dikompilasi

Satu keluarga ide, beda penggunaan: alih-alih menyimpan state aplikasi, simpan artefak build. Job build mengompilasi kode sekali (saat push ke master), lalu push dist/ + node_modules/ ke tag runtime. Job cron checkout langsung tag itu daripada menjalankan bun install && bun run build setiap eksekusi:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# tidak ada install, tidak ada build -- kode sudah siap
- run: node dist/index.js --action

Ini mengubah waktu run dari ~20 detik ke ~10 detik. Pada cron yang sering jalan, ini berarti. actions/cache melakukan pekerjaan serupa (cache dependensi), tapi git tag lebih langsung ketika kamu ingin membekukan artefak berversi secara utuh dan menunjuknya secara eksplisit -- bukan cuma mempercepat npm install.

3. Satu required check yang menggabungkan banyak job

Pola kecil yang kelihatannya sepele tapi mengubah hidup dalam konfigurasi branch protection. Di konosuba-rpg, CI punya tiga job independen (typecheck, lint, tests) yang berjalan paralel -- dan job keempat, test-battery, yang tidak melakukan apa pun selain bergantung pada tiga job pertama:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Tanpa job fasad ini, mengonfigurasi branch yang dilindungi akan mengharuskan mencentang tiga check wajib terpisah -- dan memperbarui daftar itu setiap kali job ditambahkan atau diganti nama. Dengan test-battery, cukup satu nama untuk dicentang di pengaturan repo, yang tetap stabil meskipun detail internal berubah.

4. Mengubah runner gratis menjadi VPS sementara

Ini yang paling nyeleneh, dan jelas favorit saya: repo-to-vps sepenuhnya membajak penggunaan yang dimaksud dari runner GitHub Actions untuk menjadikannya mesin Linux yang bisa diakses via SSH, gratis, hingga 6 jam (durasi maksimum job).

Prinsipnya: sebuah job yang hampir tidak melakukan apa pun selain menjalankan tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Masalah sebenarnya adalah filesystem runner GitHub Actions bersifat sekali pakai -- begitu job selesai, semuanya hilang. Sesi SSH yang berlangsung berjam-jam tidak berguna jika semua yang kamu lakukan menguap pada run berikutnya. Solusinya: branch git yang berfungsi sebagai snapshot langsung dari filesystem, disinkronkan terus-menerus.

Script start-tmate.sh melakukan, secara berurutan:

  1. Memulihkan filesystem dari branch filesystem khusus saat job dimulai (git reset --hard ke branch itu).
  2. Mengawasi perubahan file secara terus-menerus dengan inotifywait, dan commit + push segera begitu ada file yang berubah:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Setiap penyimpanan mengamend commit sebelumnya alih-alih membuat yang baru (git commit --amend --no-edit), jadi branch filesystem selalu berada di satu commit -- tidak ada penumpukan ribuan snapshot.
  2. Loop while true memulai ulang tmate secara otomatis jika sesi mati, dengan remain-on-exit on agar terminal tetap bisa dijangkau bahkan setelah exit.
  3. URL SSH yang dihasilkan tmate ditulis ke file host.conf, di-commit ke branch filesystem -- bisa diambil via GitHub API (gh api .../contents/host.conf) tanpa pernah punya akses langsung ke log job.
  4. Rutin periodic_save berjalan setiap 5 detik di latar belakang, kalau-kalau inotifywait melewatkan event.

Hasilnya: shell Linux lengkap, bisa diakses dari mana saja, dengan filesystem yang bertahan antar sesi -- padahal infrastruktur di bawahnya (runner GitHub Actions) sama sekali tidak dirancang untuk ini. Satu-satunya batasan nyata adalah timeout 6 jam per job -- setelah itu harus memulai ulang workflow.

5. Bot yang membuka PR-nya sendiri

Di konosuba-rpg, push ke branch dev memicu job yang memeriksa apakah sudah ada PR terbuka ke main -- dan membuatnya secara otomatis jika belum, via actions/github-script dan GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Detail yang penting di sini adalah token yang digunakan. Workflow ini tidak menggunakan GITHUB_TOKEN otomatis -- ia memerlukan secret AUTO_PR_TOKEN terpisah, dan menolak melanjutkan jika tidak ada:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Publish ke npm tanpa secret sama sekali

Yang paling tenang dari kelimanya, tapi mungkin paling penting untuk masa depan: workflow publish.yml dari typescript-virtual-container tidak mengandung secret npm apa pun. Tidak ada NPM_TOKEN, tidak ada NODE_AUTH_TOKEN. Hanya ini:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish tetap berfungsi, karena registry npm sekarang mendukung trusted publishing via OIDC: workflow membuktikan identitasnya langsung ke registry (repo persis + workflow persis, dikonfigurasi di sisi npmjs.org), tanpa ada token statis yang transit atau disimpan di mana pun. Nol secret yang bisa bocor, nol token yang harus dirotasi setiap enam bulan.


GitHub secrets, secara mendalam

Kelima pola ini semuanya menyentuh, dengan satu atau lain cara, persoalan secrets. Beberapa prinsip yang berulang di semua workflow saya:

Sebuah secret tidak selalu berupa string sederhana. Di email-autoreply, ACCOUNTS_JSON berisi seluruh JSON terminifikasi dari konfigurasi multi-akun -- bukan cuma API key, struktur data lengkap, diinjeksi begitu saja ke file saat runtime:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Ini menghindari harus commit file konfigurasi, bahkan yang terenkripsi, dan bisa diperbarui dengan satu klik di pengaturan repo tanpa menyentuh kode.

GITHUB_TOKEN punya batasan yang tepat, dan itu disengaja. Token otomatis yang disuntikkan GitHub ke setiap run itu kuat, tapi disegel pada titik-titik tertentu: secara default ia tidak bisa memicu workflow lain, dan tergantung konfigurasi repo bisa diblokir oleh aturan branch protection. Itulah kenapa create-pull-request.yml memerlukan PAT terpisah (AUTO_PR_TOKEN) -- token dari akun sungguhan (atau GitHub App), dengan hak eksplisit contents:write + pull-requests:write, terpisah dari token sementara job.

Izin di-scope per job, bukan global. Setiap workflow yang saya daftarkan di sini mendeklarasikan blok permissions: minimal dan dikomentari:

permissions:
  contents: read
  actions: read
  checks: write

GITHUB_TOKEN bawaan secara historis punya hak yang cukup luas di repo publik; membatasinya secara eksplisit ke apa yang benar-benar dibutuhkan job membatasi kerusakan jika action pihak ketiga dalam rantai ternyata terkompromi.

Secret terbaik adalah yang tidak ada. Pola OIDC dari typescript-virtual-container adalah versi paling lengkap dari ide ini: alih-alih mengelola rotasi, kedaluwarsa, dan risiko kebocoran NPM_TOKEN, workflow membuktikan identitasnya secara kriptografis (repo persis ini, workflow persis ini) langsung ke layanan pihak ketiga. Logika yang sama tersedia untuk AWS, Docker Hub, PyPI -- semakin banyak registry dan cloud yang mendukung OIDC dari GitHub Actions.


3 poin kunci

  1. Sebuah git tag (orphan, force-push) bisa berfungsi sebagai database minimalis atau cache build yang sudah dikompilasi -- dua penggunaan berbeda dari mekanisme yang sama.
  2. Runner GitHub Actions gratis bisa menjadi shell SSH persisten jika kamu menerima untuk terus menyinkronkan filesystem-nya ke branch git, dengan autosave via inotifywait dan satu commit yang diamend.
  3. GITHUB_TOKEN bawaan sengaja dibatasi -- membuat PR lintas branch atau publish tanpa secret memerlukan PAT khusus, atau beralih ke OIDC trusted publishing.

GitHub Actions का रचनात्मक उपयोग करने के 5 तरीके (और secrets के बारे में ये क्या सिखाते हैं)

CI runner मुफ़्त VPS में बदला, खुद PR खोलने वाला बॉट, बिना किसी secret के npm publish। अपने repos का दौरा, उन GitHub Actions पैटर्न्स की सूची जो \"lint + test + deploy\" से आगे जाते हैं।

GitHub Actions का रचनात्मक उपयोग करने के 5 तरीके

कागज़ पर, GitHub Actions क्लासिक CI/CD के लिए है: आप push करते हैं, यह lint करता है, test करता है, deploy करता है। मैंने एक विशेष केस पर पहले ही लिखा है -- email bot के लिए git tags को database की तरह इस्तेमाल करना (समर्पित आर्टिकल देखें)। लेकिन अपने repos खंगालने पर, इतने अलग-अलग पैटर्न हैं कि यह एक अलग आर्टिकल के लायक है, किसी एक प्रोजेक्ट पर कम फोकस, तकनीकों का कैटलॉग ज़्यादा।

पाँच चीज़ें, सबसे क्लासिक से सबसे पेचीदा तक।

1. git tag रन के बीच स्थायी स्टेट के रूप में

त्वरित रिकैप, पूरी डिटेल email-autoreply आर्टिकल में है। GitHub Actions डिज़ाइन से स्टेटलेस है -- हर रन एक खाली मशीन से शुरू होता है। जुगाड़: एक वैल्यू (ID, टाइमस्टैम्प, कोई भी छोटा स्टेट) एक समर्पित git tag में स्टोर करो, ब्रांच में कभी नहीं।

# स्टेट पढ़ें
git show refs/tags/lastid:data/lastId > data/lastId

# स्टेट लिखें (orphan branch, सिंगल commit, tag force-push)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

मुख्य बिंदु: हिस्ट्री कभी जमा न हो इसलिए ऑर्फन ब्रांच, और ब्रांच की जगह फोर्स्ड टैग ताकि repo की ब्रांच लिस्ट गंदी न हो।

2. git tag प्रीकंपाइल्ड बिल्ड कैश के रूप में

एक ही आइडिया फैमिली, अलग उपयोग: एप्लिकेशन स्टेट के बजाय, एक बिल्ड आर्टिफैक्ट स्टोर करते हैं। build जॉब कोड को एक बार कंपाइल करता है (master पर push पर), फिर dist/ + node_modules/ को runtime टैग में push करता है। cron जॉब हर बार bun install && bun run build चलाने के बजाय सीधे उस टैग को checkout करता है:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# कोई install नहीं, कोई build नहीं -- कोड तैयार है
- run: node dist/index.js --action

यह रन टाइम को ~20s से ~10s कर देता है। अक्सर चलने वाले cron पर, यह मायने रखता है। actions/cache समान काम करता है (डिपेंडेंसी कैश करना), लेकिन git tag ज़्यादा डायरेक्ट है जब आप एक वर्ज़न्ड आर्टिफैक्ट को पूरी तरह फ्रीज़ करके स्पष्ट रूप से पॉइंट करना चाहते हैं -- सिर्फ npm install तेज़ करना नहीं।

3. एक सिंगल रिक्वायर्ड चेक जो कई जॉब्स को एग्रीगेट करता है

एक छोटा पैटर्न जो ज़्यादा नहीं दिखता लेकिन ब्रांच प्रोटेक्शन कॉन्फिग में सब कुछ बदल देता है। konosuba-rpg पर, CI के तीन इंडिपेंडेंट जॉब (typecheck, lint, tests) पैरेलल चलते हैं -- और चौथा जॉब, test-battery, जो कुछ नहीं करता सिवाय पहले तीन पर डिपेंड करने के:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

इस फ़साद जॉब के बिना, प्रोटेक्टेड ब्रांच कॉन्फ़िगर करने के लिए तीन अलग-अलग ज़रूरी चेक टिक करने पड़ते -- और हर बार जॉब जुड़ने या रीनेम होने पर वह लिस्ट अपडेट करनी पड़ती। test-battery के साथ, repo सेटिंग्स में सिर्फ एक नाम टिक करना है, जो इंटरनल डिटेल बदलने पर भी स्टेबल रहता है।

4. मुफ़्त runner को टेंपरेरी VPS में बदलना

यह सबसे पेचीदा है, और साफ तौर पर मेरा फेवरेट: repo-to-vps GitHub Actions runner के इच्छित उपयोग को पूरी तरह हाईजैक करके इसे SSH से एक्सेस होने वाली Linux मशीन बना देता है। मुफ़्त। 6 घंटे तक (एक जॉब की अधिकतम अवधि)।

सिद्धांत: एक जॉब जो tmate लॉन्च करने के अलावा लगभग कुछ नहीं करता:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

असली सिरदर्द यह है कि GitHub Actions runner का फ़ाइलसिस्टम डिस्पोज़ेबल है -- जॉब खत्म होते ही सब गायब। घंटों चलने वाला SSH सेशन बेकार है अगर आपने जो कुछ भी किया वह अगले रन में उड़ जाए। समाधान: एक git ब्रांच जो फ़ाइलसिस्टम के लाइव स्नैपशॉट का काम करे, लगातार सिंक।

start-tmate.sh स्क्रिप्ट क्रम से यह करती है:

  1. जॉब स्टार्ट पर एक समर्पित filesystem ब्रांच से फ़ाइलसिस्टम रीस्टोर करती है (git reset --hard)।
  2. inotifywait से लगातार फ़ाइल बदलावों को वॉच करती है, और फ़ाइल हिलते ही तुरंत commit + push:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. हर सेव पिछले commit को नया बनाने के बजाय amend करता है (git commit --amend --no-edit), इसलिए filesystem ब्रांच हमेशा एक ही commit पर रहती है -- हज़ारों स्नैपशॉट का जमावड़ा नहीं।
  2. while true लूप सेशन मरने पर tmate को ऑटोमैटिक रीस्टार्ट करता है, remain-on-exit on के साथ ताकि exit के बाद भी टर्मिनल पहुँच योग्य रहे।
  3. tmate द्वारा जनरेट किया गया SSH URL host.conf फ़ाइल में लिखा जाता है, filesystem ब्रांच पर commit -- जॉब के लॉग्स तक लाइव एक्सेस के बिना भी GitHub API (gh api .../contents/host.conf) से रिट्रीव किया जा सकता है।
  4. periodic_save रूटीन हर 5 सेकंड में बैकग्राउंड में चलता है, अगर inotifywait कोई इवेंट मिस कर दे तो।

नतीजा: एक पूरा Linux शेल, कहीं से भी एक्सेस किया जा सकता है, फ़ाइलसिस्टम सेशन के बीच बना रहता है -- जबकि अंडरलाइंग इंफ़्रास्ट्रक्चर (GitHub Actions runner) बिल्कुल इसके लिए डिज़ाइन नहीं किया गया था। एकमात्र असली सीमा है प्रति जॉब 6-घंटे का टाइमआउट -- जिसके बाद वर्कफ़्लो रीस्टार्ट करना पड़ता है।

5. खुद अपनी PR खोलने वाला बॉट

konosuba-rpg पर, dev ब्रांच पर push एक जॉब ट्रिगर करता है जो चेक करता है कि main की ओर कोई खुली PR पहले से मौजूद है या नहीं -- और नहीं होने पर ऑटोमैटिकली बना देता है, actions/github-script और GitHub REST API से:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

यहाँ जो डिटेल मायने रखती है वह है इस्तेमाल किया गया टोकन। यह वर्कफ़्लो ऑटोमैटिक GITHUB_TOKEN का इस्तेमाल नहीं करता -- यह एक अलग AUTO_PR_TOKEN सीक्रेट की माँग करता है, और न होने पर जारी रखने से मना कर देता है:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. बिना किसी secret के npm पर publish करना

पाँचों में सबसे शांत, लेकिन शायद भविष्य के लिए सबसे महत्वपूर्ण: typescript-virtual-container के publish.yml वर्कफ़्लो में कोई npm सीक्रेट नहीं है। न NPM_TOKEN, न NODE_AUTH_TOKEN। बस यह:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish फिर भी काम करता है, क्योंकि npm रजिस्ट्री अब OIDC के ज़रिए trusted publishing सपोर्ट करती है: वर्कफ़्लो सीधे रजिस्ट्री को अपनी पहचान साबित करता है (सटीक repo + सटीक वर्कफ़्लो, npmjs.org साइड पर कॉन्फ़िगर), बिना किसी स्टैटिक टोकन के कहीं ट्रांज़िट या स्टोर हुए। लीक होने के लिए ज़ीरो सीक्रेट, हर छह महीने में रोटेट करने के लिए ज़ीरो टोकन।


GitHub secrets, गहराई में

ये पाँचों पैटर्न किसी न किसी तरह से सीक्रेट के सवाल को छूते हैं। कुछ सिद्धांत जो मेरे सभी वर्कफ़्लो में बार-बार आते हैं:

सीक्रेट ज़रूरी नहीं कि एक सिंपल स्ट्रिंग हो। email-autoreply में, ACCOUNTS_JSON मल्टी-अकाउंट कॉन्फ़िग का पूरा मिनिफ़ाइड JSON रखता है -- सिर्फ API की नहीं, एक पूरी डेटा स्ट्रक्चर, रनटाइम पर जस की तस फ़ाइल में इंजेक्ट:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

यह कॉन्फ़िग फ़ाइल commit करने से बचाता है, एन्क्रिप्टेड भी, और repo सेटिंग्स में एक क्लिक से बिना कोड छुए अपडेट हो जाता है।

GITHUB_TOKEN की सटीक सीमाएँ हैं, और यह जानबूझकर है। GitHub हर रन में जो ऑटोमैटिक टोकन इंजेक्ट करता है वह शक्तिशाली है, लेकिन कुछ बिंदुओं पर सील है: डिफ़ॉल्ट रूप से यह दूसरा वर्कफ़्लो ट्रिगर नहीं कर सकता, और repo कॉन्फ़िग के अनुसार ब्रांच प्रोटेक्शन रूल्स से ब्लॉक हो सकता है। इसीलिए create-pull-request.yml एक अलग PAT (AUTO_PR_TOKEN) की माँग करता है -- असली अकाउंट (या GitHub App) का टोकन, स्पष्ट contents:write + pull-requests:write अधिकारों के साथ, जॉब के अस्थायी टोकन से अलग।

परमिशन जॉब दर जॉब स्कोप होती हैं, ग्लोबली नहीं। यहाँ लिस्टेड हर वर्कफ़्लो एक मिनिमल, कमेंटेड permissions: ब्लॉक डिक्लेयर करता है:

permissions:
  contents: read
  actions: read
  checks: write

डिफ़ॉल्ट GITHUB_TOKEN का ऐतिहासिक रूप से पब्लिक repo पर काफ़ी व्यापक अधिकार होता है; इसे स्पष्ट रूप से सिर्फ उसी तक सीमित करना जो जॉब को वास्तव में चाहिए, नुकसान को सीमित करता है अगर चेन में कोई थर्ड-पार्टी एक्शन कंप्रोमाइज़ हो जाए।

सबसे अच्छा सीक्रेट वह है जो मौजूद ही नहीं है। typescript-virtual-container का OIDC पैटर्न इस विचार का सबसे पूर्ण संस्करण है: NPM_TOKEN के रोटेशन, एक्सपायरी और लीक रिस्क को मैनेज करने के बजाय, वर्कफ़्लो क्रिप्टोग्राफ़िक रूप से अपनी पहचान (यह सटीक repo, यह सटीक वर्कफ़्लो) सीधे थर्ड-पार्टी सर्विस को साबित करता है। यही लॉजिक AWS, Docker Hub, PyPI के लिए भी उपलब्ध -- ज़्यादा से ज़्यादा रजिस्ट्री और क्लाउड GitHub Actions से OIDC सपोर्ट कर रहे हैं।


3 मुख्य बिंदु

  1. एक git tag (orphan, force-pushed) मिनिमलिस्ट डेटाबेस या प्रीकंपाइल्ड बिल्ड कैश के रूप में काम कर सकता है -- एक ही मैकेनिज़्म के दो अलग-अलग उपयोग।
  2. एक मुफ़्त GitHub Actions runner एक स्थायी SSH शेल बन सकता है अगर आप उसके फ़ाइलसिस्टम को inotifywait से ऑटोसेव करके और एक सिंगल अमेंडेड commit के साथ git ब्रांच में लगातार सिंक करना स्वीकार करें।
  3. डिफ़ॉल्ट GITHUB_TOKEN जानबूझकर सीमित है -- क्रॉस-ब्रांच PR बनाने या बिना सीक्रेट के publish करने के लिए या तो समर्पित PAT चाहिए, या OIDC trusted publishing पर स्विच करना होगा।

5 طرق بارعة لاستخدام GitHub Actions (وما تعلمه عن الأسرار)

محول CI يتحول إلى VPS مجاني، بوت يفتح طلبات السحب الخاصة به، نشر npm بدون أي سر. جولة في مستودعاتي لفهرسة أنماط GitHub Actions التي تتجاوز مجرد \"lint + test + deploy\".

5 طرق بارعة لاستخدام GitHub Actions

على الورق، GitHub Actions مصمم لـ CI/CD الكلاسيكي: تدفع، يفحص، يختبر، ينشر. لقد كتبت بالفعل عن حالة خاصة -- استخدام git tags كقاعدة بيانات لبوت بريد إلكتروني (انظر المقال المخصص). لكن بالتنقيب في مستودعاتي، هناك أنماط مختلفة كافية لتستحق مقالاً مستقلاً، أقل تركيزاً على مشروع واحد، وأكثر كفهرس للتقنيات.

خمسة أشياء، من الأكثر كلاسيكية إلى الأكثر التواءً.

1. git tag كحالة دائمة بين التشغيلات

ملخص سريع، التفاصيل الكاملة في مقال email-autoreply. GitHub Actions بلا حالة حسب التصميم -- كل تشغيل يبدأ من آلة فارغة. الالتفاف: تخزين قيمة (معرف، طابع زمني، أي حالة صغيرة) في git tag مخصص، وليس في فرع أبداً.

# قراءة الحالة
git show refs/tags/lastid:data/lastId > data/lastId

# كتابة الحالة (فرع يتيم، commit واحد، force-push للوسم)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

النقطة الأساسية: فرع يتيم لعدم تراكم التاريخ أبداً، ووسم قسري بدلاً من فرع لعدم تلويث قائمة الفروع في المستودع.

2. git tag كذاكرة تخزين مؤقت للبناء المجمع مسبقاً

نفس عائلة الأفكار، استخدام مختلف: بدلاً من تخزين حالة التطبيق، نخزن أثر بناء. وظيفة build تجمع الكود مرة واحدة (عند الدفع إلى master)، ثم تدفع dist/ + node_modules/ في وسم runtime. وظيفة cron تسحب ذلك الوسم مباشرة بدلاً من تشغيل bun install && bun run build في كل تنفيذ:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# لا تثبيت، لا بناء -- الكود جاهز
- run: node dist/index.js --action

هذا يغير وقت التنفيذ من ~20 ثانية إلى ~10 ثوانٍ. على cron يعمل كثيراً، هذا مهم. actions/cache يقوم بعمل مشابه (تخزين التبعيات مؤقتاً)، لكن git tag أكثر مباشرة عندما تريد تجميد أثر مُؤَرْخ بالكامل والإشارة إليه بشكل صريح -- ليس مجرد تسريع npm install.

3. فحص إلزامي واحد يجمع عدة وظائف

نمط صغير لا يبدو كبيراً لكنه يغير الحياة في إعداد حماية الفروع. على konosuba-rpg، لدى CI ثلاث وظائف مستقلة (typecheck، lint، tests) تعمل بالتوازي -- ووظيفة رابعة، test-battery، لا تفعل شيئاً سوى الاعتماد على الثلاث الأولى:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

بدون وظيفة الواجهة هذه، سيتطلب تكوين فرع محمي تحديد ثلاث فحوصات إلزامية منفصلة -- وتحديث تلك القائمة في كل مرة تُضاف فيها وظيفة أو يُعاد تسميتها. مع test-battery، اسم واحد فقط لتحديده في إعدادات المستودع، يبقى مستقراً حتى لو تغيرت التفاصيل الداخلية.

4. تحويل مشغل مجاني إلى VPS مؤقت

هذا الأكثر التواءً على الإطلاق، وبوضوح المفضل لدي: repo-to-vps يحول تماماً الاستخدام المقصود لمشغل GitHub Actions ليصبح آلة Linux يمكن الوصول إليها عبر SSH، مجاناً، لمدة تصل إلى 6 ساعات (المدة القصوى لوظيفة).

المبدأ: وظيفة لا تفعل شيئاً تقريباً سوى تشغيل tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

المشكلة الحقيقية هي أن نظام ملفات مشغل GitHub Actions قابل للاستبدال -- بمجرد انتهاء الوظيفة، يختفي كل شيء. جلسة SSH تدوم لساعات لا فائدة منها إذا كان كل ما تفعله يتبخر في التشغيل التالي. الحل: فرع git يعمل كلقطة حية لنظام الملفات، متزامن باستمرار.

سكريبت start-tmate.sh يقوم، بالترتيب:

  1. يستعيد نظام الملفات من فرع filesystem المخصص عند بدء الوظيفة (git reset --hard عليه).
  2. يراقب تغييرات الملفات باستمرار باستخدام inotifywait، و يcommit + يpush فوراً بمجرد تحرك ملف:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. كل حفظ يعدل الـ commit السابق بدلاً من إنشاء واحد جديد (git commit --amend --no-edit)، لذلك يبقى فرع filesystem دائماً عند commit واحد -- لا تراكم لآلاف اللقطات.
  2. حلقة while true تعيد تشغيل tmate تلقائياً إذا ماتت الجلسة، مع remain-on-exit on ليظل الطرفية قابلة للوصول حتى بعد exit.
  3. عنوان SSH الذي يولده tmate يُكتب في ملف host.conf، ويُcommit على فرع filesystem -- قابل للاسترجاع عبر GitHub API (gh api .../contents/host.conf) دون الحاجة للوصول المباشر إلى سجلات الوظيفة.
  4. روتين periodic_save يعمل كل 5 ثوانٍ في الخلفية، في حال فات inotifywait حدثاً.

النتيجة: صدفة Linux كاملة، يمكن الوصول إليها من أي مكان، مع نظام ملفات يستمر بين الجلسات -- مع أن البنية التحتية الأساسية (مشغل GitHub Actions) لم تُصمم أبداً لهذا. القيد الحقيقي الوحيد هو المهلة البالغة 6 ساعات لكل وظيفة -- بعدها يجب إعادة تشغيل سير العمل.

5. بوت يفتح طلبات السحب الخاصة به

على konosuba-rpg، الدفع إلى فرع dev يشغل وظيفة تتحقق مما إذا كان هناك طلب سحب مفتوح إلى main بالفعل -- وتنشئ واحداً تلقائياً إذا لم يكن، عبر actions/github-script و GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

التفصيل المهم هنا هو الرمز المستخدم. سير العمل هذا لا يستخدم GITHUB_TOKEN التلقائي -- إنه يتطلب سر AUTO_PR_TOKEN منفصل، ويرفض المتابعة إذا كان مفقوداً:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. النشر على npm بدون أي سر

الأكثر هدوءً من الخمسة، لكنه ربما الأكثر أهمية للمستقبل: سير العمل publish.yml لـ typescript-virtual-container لا يحتوي على أي سر npm. لا NPM_TOKEN، ولا NODE_AUTH_TOKEN. فقط هذا:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish يعمل رغم ذلك، لأن سجل npm يدعم الآن النشر الموثوق عبر OIDC: سير العمل يثبت هويته مباشرة للسجل (مستودع محدد + سير عمل محدد، مكونان من جانب npmjs.org)، دون أن يمر أي رمز ثابت أو يُخزن في أي مكان. صفر أسرار للتسرب، صفر رموز للتدوير كل ستة أشهر.


أسرار GitHub، بعمق

هذه الأنماط الخمسة تلمس جميعها، بطريقة أو بأخرى، مسألة الأسرار. بعض المبادئ التي تتكرر في كل سير عمل لدي:

السر ليس بالضرورة سلسلة بسيطة. في email-autoreply، يحتوي ACCOUNTS_JSON على JSON المضغوط بالكامل لإعدادات الحسابات المتعددة -- ليس مجرد مفتاح API، بل بنية بيانات كاملة، تُحقن كما هي في ملف وقت التشغيل:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

هذا يتجنب الحاجة إلى commit ملف إعدادات، حتى لو كان مشفراً، ويمكن تحديثه بنقرة واحدة في إعدادات المستودع دون لمس الكود.

GITHUB_TOKEN له حدود دقيقة، وهذا مقصود. الرمز التلقائي الذي يحقنه GitHub في كل تشغيل قوي، لكنه مختوم في نقاط معينة: افتراضياً لا يمكنه تشغيل سير عمل آخر، وحسب إعدادات المستودع يمكن أن يُمنع بقواعد حماية الفروع. لهذا السبب تحديداً يتطلب create-pull-request.yml PAT منفصلاً (AUTO_PR_TOKEN) -- رمز من حساب حقيقي (أو GitHub App)، بصلاحيات صريحة contents:write + pull-requests:write، منفصل عن الرمز المؤقت للوظيفة.

الصلاحيات محددة النطاق وظيفة بوظيفة، وليس بشكل عام. كل سير عمل أدرجته هنا يعلن كتلة permissions: دنيا ومعلّق عليها:

permissions:
  contents: read
  actions: read
  checks: write

GITHUB_TOKEN الافتراضي تاريخياً له صلاحيات واسعة نسبياً على مستودع عام؛ تقييده صراحة لما تحتاجه الوظيفة فعلاً يحد من الضرر إذا تبين أن إجراءً خارجياً في السلسلة مخترق.

أفضل سر هو الذي لا يوجد. نمط OIDC من typescript-virtual-container هو النسخة الأكثر اكتمالاً من هذه الفكرة: بدلاً من إدارة التدوير والانتهاء وخطر تسرب NPM_TOKEN، يثبت سير العمل هويته تشفيرياً (هذا المستودع المحدد، سير العمل المحدد هذا) مباشرة للخدمة الخارجية. نفس المنطق متاح لـ AWS وDocker Hub وPyPI -- المزيد والمزيد من السجلات والسحابات تدعم OIDC من GitHub Actions.


3 نقاط رئيسية

  1. git tag (يتيم، مدفوع بالقوة) يمكن أن يعمل كقاعدة بيانات بسيطة أو ذاكرة تخزين مؤقت للبناء المجمع مسبقاً -- استخدامان مختلفان لنفس الآلية.
  2. مشغل GitHub Actions المجاني يمكن أن يصبح صدفة SSH دائمة إذا قبلت بمزامنة نظام ملفاته باستمرار إلى فرع git، مع حفظ تلقائي عبر inotifywait و commit معدل واحد.
  3. GITHUB_TOKEN الافتراضي محدود عن قصد -- إنشاء طلبات سحب بين الفروع أو النشر بدون أسرار يتطلب إما PAT مخصصاً، أو التحول إلى OIDC trusted publishing.

5 cách sáng tạo để dùng GitHub Actions (và những gì chúng dạy về secrets)

CI runner biến thành VPS miễn phí, bot tự mở pull request cho chính nó, publish npm không cần secret. Một chuyến tham quan các repo để liệt kê các pattern GitHub Actions vượt ra ngoài \"lint + test + deploy\".

5 cách sáng tạo để dùng GitHub Actions

Trên lý thuyết, GitHub Actions dành cho CI/CD cổ điển: bạn push, nó lint, test, deploy. Tôi đã viết về một trường hợp đặc biệt -- dùng git tag làm cơ sở dữ liệu cho bot email (xem bài riêng). Nhưng lục lại các repo của mình, có đủ pattern khác nhau để xứng đáng một bài riêng, ít tập trung vào một dự án, giống catalog kỹ thuật hơn.

Năm thứ, từ cổ điển nhất đến dị nhất.

1. Git tag làm trạng thái bền vững giữa các lần chạy

Tóm tắt nhanh, chi tiết đầy đủ trong bài về email-autoreply. GitHub Actions được thiết kế stateless -- mỗi lần chạy bắt đầu từ máy trắng. Cách lách: lưu một giá trị (ID, timestamp, bất kỳ trạng thái nhỏ nào) vào một git tag chuyên dụng, không bao giờ trong branch.

# đọc trạng thái
git show refs/tags/lastid:data/lastId > data/lastId

# ghi trạng thái (branch mồ côi, commit đơn, force-push tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

Điểm then chốt: branch mồ côi để không bao giờ tích lũy lịch sử, và tag bị ép thay vì branch để không làm bẩn danh sách branch của repo.

2. Git tag làm cache build đã biên dịch trước

Cùng họ ý tưởng, mục đích khác: thay vì lưu trạng thái ứng dụng, lưu một artefact build. Job build biên dịch code một lần (khi push lên master), rồi đẩy dist/ + node_modules/ vào tag runtime. Job cron checkout thẳng tag đó thay vì chạy bun install && bun run build mỗi lần:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# không install, không build -- code đã sẵn sàng
- run: node dist/index.js --action

Điều này giảm thời gian chạy từ ~20s xuống ~10s. Với cron chạy thường xuyên, có ý nghĩa đấy. actions/cache làm việc tương tự (cache dependency), nhưng git tag trực tiếp hơn khi bạn muốn đóng băng hẳn một artefact có phiên bản và trỏ rõ ràng vào nó -- không chỉ tăng tốc npm install.

3. Một check bắt buộc duy nhất gộp nhiều job

Pattern nhỏ trông không có gì nhưng thay đổi hoàn toàn cấu hình branch bảo vệ. Trên konosuba-rpg, CI có ba job độc lập (typecheck, lint, tests) chạy song song -- và job thứ tư, test-battery, không làm gì ngoài phụ thuộc vào ba job đầu:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

Không có job mặt tiền này, cấu hình branch bảo vệ sẽ phải tick ba check bắt buộc riêng biệt -- và cập nhật danh sách đó mỗi khi job được thêm vào hoặc đổi tên. Với test-battery, chỉ một tên để tick trong cài đặt repo, ổn định ngay cả khi chi tiết bên trong thay đổi.

4. Biến runner miễn phí thành VPS tạm thời

Đây là cái dị nhất, và rõ ràng là cái tôi thích nhất: repo-to-vps hoàn toàn bẻ cong mục đích sử dụng của runner GitHub Actions để biến nó thành một máy Linux truy cập được qua SSH, miễn phí, lên đến 6 giờ (thời lượng tối đa của một job).

Nguyên lý: một job hầu như không làm gì ngoài việc chạy tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

Vấn đề thực sự là hệ thống tệp của runner GitHub Actions là dùng một lần -- job kết thúc là mọi thứ biến mất. Phiên SSH kéo dài hàng giờ vô nghĩa nếu mọi thứ bạn làm bốc hơi ở lần chạy sau. Giải pháp: một branch git đóng vai snapshot trực tiếp của hệ thống tệp, đồng bộ liên tục.

Script start-tmate.sh thực hiện, theo thứ tự:

  1. Khôi phục hệ thống tệp từ branch filesystem chuyên dụng khi job bắt đầu (git reset --hard lên đó).
  2. Theo dõi thay đổi tệp liên tục với inotifywait, và commit + push ngay lập tức khi có tệp thay đổi:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. Mỗi lần lưu sửa đổi commit trước thay vì tạo commit mới (git commit --amend --no-edit), vì vậy branch filesystem luôn ở một commit duy nhất -- không tích lũy hàng nghìn snapshot.
  2. Vòng lặp while true khởi động lại tmate tự động nếu phiên chết, với remain-on-exit on để terminal vẫn truy cập được ngay cả sau exit.
  3. URL SSH do tmate tạo được ghi vào tệp host.conf, commit lên branch filesystem -- có thể lấy qua GitHub API (gh api .../contents/host.conf) mà không cần truy cập trực tiếp vào log của job.
  4. Quy trình periodic_save chạy mỗi 5 giây trong nền, phòng khi inotifywait bỏ lỡ sự kiện.

Kết quả: một shell Linux hoàn chỉnh, truy cập từ bất cứ đâu, với hệ thống tệp tồn tại giữa các phiên -- mặc dù cơ sở hạ tầng bên dưới (runner GitHub Actions) hoàn toàn không được thiết kế cho việc này. Giới hạn thực sự duy nhất là timeout 6 giờ mỗi job -- sau đó phải khởi động lại workflow.

5. Bot tự mở pull request cho chính nó

Trên konosuba-rpg, push lên branch dev kích hoạt job kiểm tra xem đã có PR mở tới main chưa -- và tự động tạo nếu chưa, qua actions/github-script và GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

Chi tiết quan trọng ở đây là token được sử dụng. Workflow này không dùng GITHUB_TOKEN tự động -- nó yêu cầu secret AUTO_PR_TOKEN riêng, và từ chối tiếp tục nếu thiếu:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. Xuất bản lên npm không cần secret

Yên tĩnh nhất trong năm cái, nhưng có lẽ quan trọng nhất cho tương lai: workflow publish.yml của typescript-virtual-container không chứa bất kỳ secret npm nào. Không NPM_TOKEN, không NODE_AUTH_TOKEN. Chỉ có thế này:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish vẫn hoạt động, vì registry npm hiện hỗ trợ trusted publishing qua OIDC: workflow chứng minh danh tính trực tiếp với registry (repo chính xác + workflow chính xác, được cấu hình phía npmjs.org), không token tĩnh nào được truyền hay lưu trữ ở bất cứ đâu. Không secret để rò rỉ, không token để xoay vòng mỗi sáu tháng.


GitHub secrets, đào sâu

Năm pattern này đều chạm đến, bằng cách này hay cách khác, vấn đề secrets. Vài nguyên tắc lặp đi lặp lại trong các workflow của tôi:

Secret không nhất thiết là chuỗi đơn giản. Trong email-autoreply, ACCOUNTS_JSON chứa toàn bộ JSON đã nén của cấu hình đa tài khoản -- không chỉ một API key, mà là một cấu trúc dữ liệu hoàn chỉnh, được tiêm nguyên dạng vào tệp lúc runtime:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

Điều này tránh phải commit tệp cấu hình, kể cả đã mã hóa, và có thể cập nhật bằng một cú click trong cài đặt repo mà không cần chạm vào code.

GITHUB_TOKEN có giới hạn chính xác, và đó là cố ý. Token tự động GitHub tiêm vào mỗi lần chạy rất mạnh, nhưng bị niêm phong ở một số điểm: mặc định nó không thể kích hoạt workflow khác, và tùy cấu hình repo có thể bị chặn bởi quy tắc bảo vệ branch. Đó chính là lý do create-pull-request.yml yêu cầu PAT riêng (AUTO_PR_TOKEN) -- token từ tài khoản thật (hoặc GitHub App), với quyền rõ ràng contents:write + pull-requests:write, tách biệt với token tạm thời của job.

Quyền được scope theo từng job, không toàn cục. Mỗi workflow tôi liệt kê ở đây đều khai báo khối permissions: tối thiểu và có chú thích:

permissions:
  contents: read
  actions: read
  checks: write

GITHUB_TOKEN mặc định trong lịch sử có quyền khá rộng trên repo công khai; giới hạn rõ ràng chỉ những gì job thực sự cần sẽ hạn chế thiệt hại nếu một action bên thứ ba trong chuỗi bị xâm phạm.

Secret tốt nhất là secret không tồn tại. Pattern OIDC của typescript-virtual-container là phiên bản hoàn chỉnh nhất của ý tưởng này: thay vì quản lý vòng xoay, hết hạn và rủi ro rò rỉ của NPM_TOKEN, workflow chứng minh danh tính bằng mật mã (repo chính xác này, workflow chính xác này) trực tiếp với dịch vụ bên thứ ba. Logic tương tự có sẵn cho AWS, Docker Hub, PyPI -- ngày càng nhiều registry và cloud hỗ trợ OIDC từ GitHub Actions.


3 điểm chính

  1. Một git tag (mồ côi, force-push) có thể làm cơ sở dữ liệu tối giản hoặc cache build đã biên dịch trước -- hai cách dùng khác nhau của cùng một cơ chế.
  2. Một runner GitHub Actions miễn phí có thể trở thành shell SSH bền vững nếu bạn chấp nhận đồng bộ liên tục hệ thống tệp của nó vào một branch git, với tự động lưu qua inotifywait và một commit sửa đổi duy nhất.
  3. GITHUB_TOKEN mặc định bị giới hạn có chủ đích -- tạo PR giữa các branch hoặc xuất bản không cần secret đòi hỏi hoặc PAT chuyên dụng, hoặc chuyển sang OIDC trusted publishing.

5 วิธีใช้ GitHub Actions อย่างแยบยล (และสิ่งที่สอนเกี่ยวกับ secrets)

CI runner กลายเป็น VPS ฟรี บอทที่เปิด PR ให้ตัวเอง การ publish npm แบบไม่มี secret เลยสักตัว ทัวร์ repo ต่างๆ เพื่อจัดแคตตาล็อก pattern GitHub Actions ที่มากกว่าแค่ \"lint + test + deploy\"

5 วิธีใช้ GitHub Actions อย่างแยบยล

บนกระดาษ GitHub Actions มีไว้สำหรับ CI/CD แบบคลาสสิก: คุณ push มันก็ lint, test, deploy ผมเคยเขียนถึงกรณีพิเศษแล้ว -- การใช้ git tag เป็นฐานข้อมูลให้บอทอีเมล (ดูบทความเฉพาะ) แต่พอขุดดู repo ตัวเอง มี pattern แตกต่างกันมากพอที่จะคุ้มกับบทความเดี่ยว ที่ไม่โฟกัสแค่โปรเจกต์เดียว แต่เป็นแคตตาล็อกเทคนิคมากกว่า

ห้าอย่าง จากคลาสสิกที่สุดไปยันบิดเบี้ยวที่สุด

1. git tag เป็นสถานะถาวรระหว่างรัน

สรุปสั้น ๆ รายละเอียดเต็มอยู่ในบทความ email-autoreply GitHub Actions ถูกออกแบบมาแบบ stateless -- ทุกครั้งที่รันเริ่มจากเครื่องเปล่า วิธีเลี่ยง: เก็บค่า (ID, timestamp, สถานะเล็ก ๆ อะไรก็ได้) ใน git tag เฉพาะ ไม่ใช่ใน branch

# อ่านสถานะ
git show refs/tags/lastid:data/lastId > data/lastId

# เขียนสถานะ (orphan branch, commit เดียว, force-push tag)
git switch --orphan lastid-tmp
git commit -m "lastId snapshot"
git tag -f lastid
git push --force origin lastid

จุดสำคัญ: orphan branch เพื่อไม่ให้ประวัติสะสม และ forced tag แทน branch เพื่อไม่ให้รายการ branch ของ repo รก

2. git tag เป็นแคช build ที่คอมไพล์ไว้แล้ว

ตระกูลไอเดียเดียวกัน ใช้ต่างกัน: แทนที่จะเก็บสถานะแอป เก็บ artefact ของ build แทน job build คอมไพล์โค้ดครั้งเดียว (ตอน push ไป master) แล้ว push dist/ + node_modules/ ใส่ tag runtime job cron checkout tag นั้นโดยตรงแทนที่จะรัน bun install && bun run build ทุกครั้ง:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime
    fetch-depth: 1
# ไม่มี install ไม่มี build -- โค้ดพร้อมแล้ว
- run: node dist/index.js --action

นี่เปลี่ยนเวลารันจาก ~20s เหลือ ~10s สำหรับ cron ที่รันบ่อย ๆ มันสำคัญ actions/cache ก็ทำงานคล้ายกัน (แคช dependencies) แต่ git tag ตรงไปตรงมากว่าเมื่อคุณต้องการแช่แข็ง artefact ที่มีเวอร์ชันทั้งหมดแล้วชี้ไปตรง ๆ -- ไม่ใช่แค่เร่ง npm install

3. check บังคับตัวเดียวที่รวมหลาย job

pattern เล็ก ๆ ที่ดูไม่มีอะไรแต่เปลี่ยนชีวิตในการตั้งค่า branch protection บน konosuba-rpg CI มีสาม job อิสระ (typecheck, lint, tests) ที่ทำงานขนานกัน -- และ job ที่สี่ test-battery ที่ไม่ทำอะไรเลยนอกจาก dependency กับสามตัวแรก:

test-battery:
  needs:
    - typecheck
    - lint
    - tests
  runs-on: ubuntu-latest
  steps:
    - run: echo "Typecheck, lint and tests succeeded."

ถ้าไม่มี job ฉากหน้านี้ การตั้งค่า branch ที่ถูกป้องกันจะต้องติ๊ก check บังคับสามตัวแยกกัน -- และอัปเดตรายการนั้นทุกครั้งที่มี job เพิ่มหรือเปลี่ยนชื่อ ด้วย test-battery แค่ชื่อเดียวที่ต้องติ๊กใน setting repo และคงที่แม้รายละเอียดภายในจะเปลี่ยน

4. เปลี่ยน runner ฟรีเป็น VPS ชั่วคราว

อันนี้บิดเบี้ยวที่สุด และเป็นอันโปรดของผมชัด ๆ: repo-to-vps แอบใช้ GitHub Actions runner ผิดวัตถุประสงค์โดยสิ้นเชิงเพื่อเปลี่ยนมันเป็นเครื่อง Linux ที่เข้าได้ผ่าน SSH ฟรี นานสูงสุด 6 ชั่วโมง (เวลาสูงสุดของ job)

หลักการ: job ที่แทบไม่ทำอะไรเลยนอกจากรัน tmate:

name: debug-runner
on:
  push:
    branches: [main, master]
  workflow_dispatch:
permissions:
  contents: write
  actions: write
jobs:
  debug:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: awalsh128/[email protected]
        with:
          packages: tmate inotify-tools
      - run: bash .github/scripts/start-tmate.sh

ปัญหาจริง ๆ คือระบบไฟล์ของ runner GitHub Actions เป็นแบบใช้แล้วทิ้ง -- พอ job จบทุกอย่างก็หายไป เซสชัน SSH ที่อยู่นานหลายชั่วโมงจะไม่มีประโยชน์ถ้าทุกสิ่งที่คุณทำระเหยไปตอนรันครั้งหน้า วิธีแก้: git branch ที่ทำหน้าที่เป็น snapshot สดของระบบไฟล์ ซิงก์ต่อเนื่อง

สคริปต์ start-tmate.sh ทำตามลำดับดังนี้:

  1. กู้คืน ระบบไฟล์จาก branch filesystem เฉพาะตอนเริ่ม job (git reset --hard ใส่)
  2. เฝ้าดู การเปลี่ยนแปลงไฟล์ต่อเนื่องด้วย inotifywait และ commit + push ทันที เมื่อมีไฟล์เคลื่อนไหว:
autosave() {
  while inotifywait -qq -r -e modify,create,delete,move \
    --exclude '(^|/)(\.git|\.apt-cache|\.cache|host\.conf|tmate\.sock)(/|$)' .; do
    commit_and_push
    sleep 1
  done
}
  1. ทุกครั้งที่เซฟ amend commit ก่อนหน้าแทนที่จะสร้างใหม่ (git commit --amend --no-edit) ดังนั้น branch filesystem จะอยู่ที่ commit เดียวเสมอ -- ไม่มี snapshot เป็นพัน ๆ สะสม
  2. ลูป while true รัน tmate ใหม่โดยอัตโนมัติถ้าเซสชันตาย โดยมี remain-on-exit on เพื่อให้ terminal ยังเข้าถึงได้แม้หลังจาก exit
  3. SSH URL ที่ tmate สร้างถูกเขียนลงไฟล์ host.conf commit บน branch filesystem -- ดึงข้อมูลได้ผ่าน GitHub API (gh api .../contents/host.conf) โดยไม่ต้องเข้าถึง log ของ job แบบสด ๆ
  4. รูทีน periodic_save ทำงานทุก 5 วินาทีในพื้นหลัง เผื่อ inotifywait พลาดเหตุการณ์

ผลลัพธ์: shell Linux เต็มรูปแบบ เข้าถึงได้จากทุกที่ พร้อมระบบไฟล์ที่คงอยู่ระหว่างเซสชัน -- ทั้งที่โครงสร้างพื้นฐานข้างใต้ (GitHub Actions runner) ไม่ได้ถูกออกแบบมาเพื่อสิ่งนี้เลย ข้อจำกัดจริง ๆ อย่างเดียวคือ timeout 6 ชั่วโมงต่อ job -- หลังจากนั้นต้องเริ่ม workflow ใหม่

5. บอทที่เปิด PR ให้ตัวเอง

บน konosuba-rpg การ push ไป branch dev จะ trigger job ที่ตรวจสอบว่ามี PR ที่เปิดไปยัง main อยู่แล้วหรือไม่ -- และสร้างให้โดยอัตโนมัติถ้ายังไม่มี ผ่าน actions/github-script และ GitHub REST API:

const { data: comparison } = await github.rest.repos.compareCommits({
  owner, repo, base: 'main', head: 'dev',
});
if (comparison.ahead_by === 0) return;

const { data: existing } = await github.rest.pulls.list({
  owner, repo, state: 'open', head: `${owner}:dev`, base: 'main',
});
if (existing.length > 0) return;

await github.rest.pulls.create({
  owner, repo, head: 'dev', base: 'main',
  title: 'chore: auto PR from dev to main',
});

รายละเอียดที่สำคัญตรงนี้คือ token ที่ใช้ workflow นี้ไม่ใช้ GITHUB_TOKEN อัตโนมัติ -- มันต้องการ secret AUTO_PR_TOKEN แยกต่างหาก และปฏิเสธที่จะทำต่อถ้าไม่มี:

- name: Validate pull request token
  env:
    AUTO_PR_TOKEN: ${{ secrets.AUTO_PR_TOKEN }}
  run: |
    if [ -z "$AUTO_PR_TOKEN" ]; then
      echo "AUTO_PR_TOKEN is required... Use a PAT or GitHub App token with contents:write and pull-requests:write."
      exit 1
    fi

6. เผยแพร่ขึ้น npm โดยไม่มี secret เลย

เงียบที่สุดในห้าอย่าง แต่น่าจะสำคัญที่สุดสำหรับอนาคต: workflow publish.yml ของ typescript-virtual-container ไม่มี secret ของ npm เลย ไม่มี NPM_TOKEN ไม่มี NODE_AUTH_TOKEN มีแค่นี้:

permissions:
  id-token: write
  contents: read
jobs:
  publish:
    steps:
      - uses: actions/setup-node@v6
        with:
          registry-url: 'https://registry.npmjs.org'
      - run: npm publish

npm publish ยังทำงานได้ เพราะ npm registry ตอนนี้รองรับ trusted publishing ผ่าน OIDC: workflow พิสูจน์ตัวตนโดยตรงกับ registry (repo ที่แน่นอน + workflow ที่แน่นอน กำหนดค่าฝั่ง npmjs.org) โดยไม่มี static token ใด ๆ ถูกส่งผ่านหรือเก็บไว้ที่ไหนเลย ไม่มี secret ให้รั่วไหล ไม่มี token ให้หมุนเวียนทุกหกเดือน


GitHub secrets แบบเจาะลึก

ทั้งห้า pattern นี้แตะต้องปัญหาเรื่อง secrets ไม่ทางใดก็ทางหนึ่ง หลักการบางอย่างที่ปรากฏซ้ำ ๆ ใน workflow ของผม:

secret ไม่จำเป็นต้องเป็นสตริงง่าย ๆ ใน email-autoreply ACCOUNTS_JSON มี JSON ที่ย่อแล้วทั้งหมดของการตั้งค่าหลายบัญชี -- ไม่ใช่แค่ API key แต่เป็นโครงสร้างข้อมูลที่สมบูรณ์ ถูกฉีดเข้าไฟล์ตอน runtime ตามนั้น:

env:
  ACCOUNTS_JSON: ${{ secrets.ACCOUNTS_JSON }}
run: printf "%s" "$ACCOUNTS_JSON" > data/accounts.json

นี่เลี่ยงการต้อง commit ไฟล์ตั้งค่า แม้จะเข้ารหัสแล้ว และอัปเดตได้ด้วยคลิกเดียวใน setting repo โดยไม่ต้องแตะโค้ด

GITHUB_TOKEN มีข้อจำกัดที่แม่นยำ และนั่นตั้งใจ token อัตโนมัติที่ GitHub ฉีดให้ทุกรันนั้นทรงพลัง แต่ถูกปิดตายในบางจุด: โดยค่าเริ่มต้นมัน trigger workflow อื่นไม่ได้ และขึ้นอยู่กับการตั้งค่า repo อาจถูกบล็อกโดยกฎ branch protection นั่นคือเหตุผลที่ create-pull-request.yml ต้องการ PAT แยก (AUTO_PR_TOKEN) -- token จากบัญชีจริง (หรือ GitHub App) ที่มีสิทธิ์ contents:write + pull-requests:write ชัดเจน แยกจาก token ชั่วคราวของ job

permissions ถูก scope ทีละ job ไม่ใช่ทั้ง global ทุก workflow ที่ผมลิสต์ไว้ที่นี่ประกาศบล็อก permissions: แบบน้อยที่สุดและมีคอมเมนต์:

permissions:
  contents: read
  actions: read
  checks: write

GITHUB_TOKEN เริ่มต้นในอดีตมีสิทธ์ค่อนข้างกว้างบน repo สาธารณะ การจำกัดมันอย่างชัดแจ้งให้เหลือแค่สิ่งที่ job ต้องการจริง ๆ จะจำกัดความเสียหายถ้า action ของบุคคลที่สามในเชนถูกบุกรุก

secret ที่ดีที่สุดคือ secret ที่ไม่มีอยู่ pattern OIDC ของ typescript-virtual-container เป็นเวอร์ชันที่สมบูรณ์ที่สุดของแนวคิดนี้: แทนที่จะจัดการการหมุนเวียน การหมดอายุ และความเสี่ยงการรั่วไหลของ NPM_TOKEN workflow พิสูจน์ตัวตนด้วยการเข้ารหัส (repo นี้แน่นอน workflow นี้แน่นอน) โดยตรงกับบริการภายนอก ตรรกะเดียวกันใช้ได้กับ AWS, Docker Hub, PyPI -- registry และ cloud จำนวนมากขึ้นเรื่อย ๆ รองรับ OIDC จาก GitHub Actions


3 ประเด็นสำคัญ

  1. git tag (orphan, force-push) สามารถทำหน้าที่เป็นฐานข้อมูลแบบมินิมอลหรือแคช build ที่คอมไพล์ไว้แล้ว -- การใช้กลไกเดียวกันสองแบบที่ต่างกัน
  2. GitHub Actions runner ฟรีสามารถกลายเป็น SSH shell แบบถาวรได้ถ้าคุณยอมรับการซิงก์ระบบไฟล์ต่อเนื่องไปยัง git branch ด้วยการเซฟอัตโนมัติผ่าน inotifywait และ commit แบบ amended เดียว
  3. GITHUB_TOKEN เริ่มต้นถูกจำกัดโดยเจตนา -- การสร้าง PR ข้าม branch หรือการ publish โดยไม่มี secret ต้องใช้ PAT เฉพาะ หรือเปลี่ยนไปใช้ OIDC trusted publishing

Related Articles